diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 29bcd61..fa6b671 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -322,7 +322,7 @@ func main() { // 文档记忆 + 知识库 // ======================================================================== - docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents")) + docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords) if err := docStore.Start(); err != nil { log.Printf("[homed] warning: document store: %v", err) } diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 5647312..3927a69 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -98,8 +98,15 @@ type Agent struct { // 子任务异步执行 childMu sync.Mutex childNextID int64 - childResults map[string]string - childRunning map[string]bool // 运行中的子任务(child_result 查询时区分'运行中'与'不存在') + // childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。 + // + // 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文 + // (formatMergedTimeline 每轮都重新注入),模型之后还会再查。若读到即删, + // 第二次查询就得到“不存在或已过期”这个**永久失败信号**——模型据此认为 + // 任务未完成,会无限重试/汇报(实测单轮 35 次工具调用、持续 514 秒)。 + childTasks map[string]*childTaskState + // childSeq 给完成的任务排个序,用于有界淘汰。 + childSeq int64 // 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取 interceptCh chan *agentIO.InputEvent @@ -227,8 +234,7 @@ func New(cfg AgentConfig) *Agent { embedder = memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...) } if cfg.DocStore != nil { - cfg.DocStore.SetVectorizer(embedder) - cfg.DocStore.ReindexWithVectorizer(embedder) + // TF-IDF 内置为 fallback,无需外部注入 } if cfg.Knowledge != nil { cfg.Knowledge.SetVectorizer(embedder) @@ -293,8 +299,7 @@ func New(cfg AgentConfig) *Agent { skillIndex: cfg.SkillIndexProvider, eventBus: cfg.EventBus, selfInputCh: make(chan selfInputMsg, 64), - childResults: make(map[string]string), - childRunning: make(map[string]bool), + childTasks: make(map[string]*childTaskState), interceptCh: make(chan *agentIO.InputEvent, 64), pluginHealth: newPluginHealthTracker(), thinkingEnabled: cfg.ThinkingEnabled, diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index a9a2dbf..27ca455 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -78,7 +78,10 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note) } } - return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result) + // 成功回执:只返回极简标记,不回传完整插件响应。 + // 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执 + // 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。 + return "ok" } a.io.EmitTextTo("agent_io", channel, payload) diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 1649d16..e5fc055 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -15,6 +15,66 @@ import ( pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) +// continuationPlaceholder 是工具轮之后补的 user 占位内容。 +// +// zen 兼容网关要求请求最后一条必须是 user(thinking 续写模式校验),工具轮 +// 产出 assistant/tool 结尾会被 400 拒绝;首轮 system 结尾不补,否则会覆盖 +// 真实用户输入。 +// +// 用独立常量 + 精确等值判定,是因为这条消息是**核心自己插入的**、不是用户输入, +// 所以可以安全地按内容识别并在补位前移除上一条,保证至多一条。 +const continuationPlaceholder = "请根据以上工具结果继续。" + +// replyDeliveredPlaceholder 是「本批工具调用全部是输出通道发送」之后补的占位。 +// +// 为何不能继续用通用的「请继续」:异步通道(qq/wechat)的回复**只能**经 +// output_send__* 交付(纯文本不送达,见 buildSystemPrompt 的输出规则)。于是 +// 模型「已经回复完了」的表达形式就是一个工具调用,而紧随其后的 +// 「请根据以上工具结果继续。」会被读成「还要再做一步」——能做的「一步」恰好 +// 还是再发一条消息。两者叠加成自我强化的发送循环:生产实测单轮 34 次 +// output_send__qq、持续 514 秒,直到 QQ 插件自己的循环保险拒绝发送才停下。 +// +// 所以这里换成一条明确的终止许可:已回复完就直接返回纯文本收尾。 +const replyDeliveredPlaceholder = "若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。" + +// continuationFor 选择工具轮之后补位的 user 占位文案。 +// replyOnly 表示上一批工具调用全部是输出通道发送(即模型刚交付了回复)。 +func continuationFor(replyOnly bool) string { + if replyOnly { + return replyDeliveredPlaceholder + } + return continuationPlaceholder +} + +// isOutputDeliveryTool 判断工具是否是「向输出通道交付内容」。 +// output_send__{channel}_help 只是查询用法,不算交付。 +func isOutputDeliveryTool(name string) bool { + return strings.HasPrefix(name, "output_send__") && !strings.HasSuffix(name, "_help") +} + +// isContinuationPlaceholder 判断一条 user 消息是否是本机制插入的占位。 +// 只按两个常量精确匹配,不碰任何真实用户消息。 +func isContinuationPlaceholder(m agentAPI.Message) bool { + return m.Role == "user" && + (m.Content == continuationPlaceholder || m.Content == replyDeliveredPlaceholder) +} + +// dropContinuationPlaceholders 移除此前由本机制插入的 user 占位。 +// +// 为什么必须移除而不仅仅是“不再追加”:`msgs` 在循环外创建、循环内只增不减, +// 占位是核心自己插的、不是用户说的话。不移除的话,prompt 里就会线性叠上 +// N 条一模一样的“继续”,把前缀上下文(含记忆注入)往后挤。 +func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message { + out := msgs[:0] + for _, m := range msgs { + if isContinuationPlaceholder(m) { + continue + } + out = append(out, m) + } + return out +} + func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) { a.mu.Lock() defer a.mu.Unlock() @@ -63,6 +123,9 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } } + // lastBatchReplyOnly 记录上一批工具调用是否全部是输出通道发送。 + lastBatchReplyOnly := false + for turn := 0; ; turn++ { for _, interrupt := range a.drainInterrupts() { msgs = append(msgs, agentAPI.Message{ @@ -75,10 +138,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri // 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。 // 注意:仅当尾部确为工具轮产物(assistant/tool)时才补位;首轮 system 上下文结尾不补, // 否则会错误覆盖实际用户输入(如 injectSourceContext 追加的 system 说明)。 + // + // 补位前先移除前面轮次插入的同类占位,保证占位**不随轮次线性累积**—— + // 占位是核心插的传输层附加物,不是用户发言,不该在 prompt 里叠成 N 条。 + // + // 文案分情况:上一批全是 output_send__* 时不能说“继续”,详见 + // replyDeliveredPlaceholder 的说明。 + msgs = dropContinuationPlaceholders(msgs) if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" { msgs = append(msgs, agentAPI.Message{ Role: "user", - Content: "请根据以上工具结果继续。", + Content: continuationFor(lastBatchReplyOnly), }) } @@ -243,6 +313,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri return resp.Content, toolsUsed, toolResults, nil } + // 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。 + // 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break, + // 放在循环里统计会漏。 + replyOnly := true + for _, tc := range resp.ToolCalls { + if !isOutputDeliveryTool(tc.Name) { + replyOnly = false + break + } + } + contentOnce := true for _, tc := range resp.ToolCalls { if len(a.interceptCh) > 0 { @@ -400,6 +481,9 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri break } } + + // 供下一轮顶部选择补位文案。 + lastBatchReplyOnly = replyOnly } } diff --git a/internal/agent/core/spawn.go b/internal/agent/core/spawn.go index e81f157..ea013d4 100644 --- a/internal/agent/core/spawn.go +++ b/internal/agent/core/spawn.go @@ -9,6 +9,42 @@ import ( agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" ) +// childTaskState 是一个子任务的生命周期状态。 +// +// delivered 代替了早期的“读到即删”:完成通知会写进持久上下文 +// (formatMergedTimeline 每轮重新注入),模型之后还会再查。读一次就删的 +// 话,第二次查询返回“不存在或已过期”——那是一个**永远不会成功的可操作 +// 信号**,模型只能一遍遍地重试/汇报,循环永不结束。 +type childTaskState struct { + running bool + result string + delivered bool // 结果是否已交付过(用于幂等应答) + seq int64 // 完成顺序,用于有界淘汰 +} + +// maxRetainedChildTasks 是保留的已完成子任务上限(防结果无限占用内存)。 +const maxRetainedChildTasks = 20 + +// evictChildTasksLocked 淘汰最旧的已完成子任务。调用方必须持有 childMu。 +func (a *Agent) evictChildTasksLocked() { + for len(a.childTasks) > maxRetainedChildTasks { + oldestID := "" + var oldestSeq int64 + for id, st := range a.childTasks { + if st.running { + continue + } + if oldestID == "" || st.seq < oldestSeq { + oldestID, oldestSeq = id, st.seq + } + } + if oldestID == "" { + return // 剩下全是运行中的,不淘汰 + } + delete(a.childTasks, oldestID) + } +} + func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { task, _ := tc.Arguments["task"].(string) if task == "" { @@ -41,11 +77,11 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { } a.childMu.Lock() - a.childRunning[taskID] = true + a.childTasks[taskID] = &childTaskState{running: true} a.childMu.Unlock() go a.runChildTask(taskID, task, parentChannel, maxTurns) - return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮),完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID, maxTurns) + return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮)。完成后会自动通知你,届时用 child_result 查看输出即可(**只需查询一次**)", taskID, maxTurns) } // defaultChildMaxTurns 子 Agent 默认工具轮数(可被 spawn_child 的 max_turns 参数覆盖)。 @@ -126,19 +162,30 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns } a.childMu.Lock() - a.childResults[taskID] = finalResult - delete(a.childRunning, taskID) + if st := a.childTasks[taskID]; st != nil { + st.running = false + st.result = finalResult + a.childSeq++ + st.seq = a.childSeq + } + a.evictChildTasksLocked() a.childMu.Unlock() log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100)) - notification := fmt.Sprintf("子任务 %s 已完成,请调用 child_result 工具查看输出", taskID) + notification := fmt.Sprintf("子任务 %s 已完成。请用 child_result 工具查看输出(只需查询一次;重复查询不会返回失败)。", taskID) a.injectSelfChannel(selfInputMsg{ text: notification, channel: parentChannel, // 回到父对话通道,正常处理(写入上下文 + emit 响应) }) } +// executeChildResultTool 取回子任务结果。 +// +// **幂等**:结果不会被“读到即删”,重复查询返回同一结果或一条明确提示。 +// 这一点至关重要——完成通知会长期留在持久上下文里(formatMergedTimeline +// 每轮重新注入),如果重复查询返回“不存在”这种失败信号,模型会认定任务 +// 未完成而无限重试(实测单轮 35 次工具调用、持续 514 秒)。 func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string { taskID, _ := tc.Arguments["task_id"].(string) if taskID == "" { @@ -146,18 +193,25 @@ func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string { } a.childMu.Lock() - result, ok := a.childResults[taskID] - if ok { - delete(a.childResults, taskID) + st, ok := a.childTasks[taskID] + if !ok { a.childMu.Unlock() - return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result) + return fmt.Sprintf("子任务 %s 不存在:从未创建该 ID(请核对 spawn_child 返回的 ID 拼写)", taskID) } - if a.childRunning[taskID] { + if st.running { a.childMu.Unlock() return fmt.Sprintf("子任务 %s 仍在运行中,尚未完成。请等待完成通知后再查询。", taskID) } + first := !st.delivered + st.delivered = true + result := st.result a.childMu.Unlock() - return fmt.Sprintf("子任务 %s 不存在或已过期", taskID) + + if first { + return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result) + } + // 重复查询不是失败:明确告诉模型“任务已完成、结果已给过”,让它停止重试。 + return fmt.Sprintf("【子任务 %s 已完成】结果已在上文提供(见先前的 child_result 工具结果),无需重复查询;请直接基于上文结果继续。", taskID) } func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string { diff --git a/internal/agent/core/spawn_test.go b/internal/agent/core/spawn_test.go new file mode 100644 index 0000000..a298bfa --- /dev/null +++ b/internal/agent/core/spawn_test.go @@ -0,0 +1,88 @@ +package core + +import ( + "fmt" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// child_result 必须幂等——这是 "任务已结束但核心循环不结束" 的根因修复。 +// +// 子任务完成通知会写进持久上下文(formatMergedTimeline 每轮重新注入), +// 模型之后还会再查。若第二次查询返回 "不存在或已过期" 这种**永久失败信号**, +// 模型会认定任务未完成而无限重试/汇报(生产实测:单轮 35 次工具调用、 +// 持续 514 秒)。 +func TestChildResultIsIdempotent(t *testing.T) { + a := New(AgentConfig{ID: "t"}) + + a.childMu.Lock() + a.childTasks["child_1"] = &childTaskState{result: "任务完成:已创建 3 个日程", seq: 1} + a.childMu.Unlock() + + call := func(id string) string { + return a.executeChildResultTool(agentAPI.ToolCall{ + Name: "child_result", + Arguments: map[string]interface{}{"task_id": id}, + }) + } + + first := call("child_1") + if !strings.Contains(first, "任务完成:已创建 3 个日程") { + t.Fatalf("首次查询应返回结果,实际: %q", first) + } + + second := call("child_1") + if strings.Contains(second, "不存在") { + t.Fatalf("重复查询不能返回失败信号(会驱动模型无限重试),实际: %q", second) + } + if !strings.Contains(second, "已完成") { + t.Fatalf("重复查询应明确告知「已完成、结果已提供」,实际: %q", second) + } + + // 只有从未创建过的 ID 才应报 "不存在"。 + missing := call("child_999") + if !strings.Contains(missing, "不存在") { + t.Fatalf("未知 ID 应报不存在,实际: %q", missing) + } +} + +// 运行中与已完成必须给出不同答复,否则模型无法判断该等还是该继续。 +func TestChildResultRunningVsDone(t *testing.T) { + a := New(AgentConfig{ID: "t"}) + + a.childMu.Lock() + a.childTasks["child_run"] = &childTaskState{running: true} + a.childMu.Unlock() + + got := a.executeChildResultTool(agentAPI.ToolCall{ + Name: "child_result", + Arguments: map[string]interface{}{"task_id": "child_run"}, + }) + if !strings.Contains(got, "仍在运行中") { + t.Fatalf("运行中的任务应提示仍在运行,实际: %q", got) + } +} + +// 保留的结果必须有界,不能随子任务数量无限增长。 +func TestChildTaskRetentionBounded(t *testing.T) { + a := New(AgentConfig{ID: "t"}) + + a.childMu.Lock() + for i := 0; i < maxRetainedChildTasks*3; i++ { + a.childSeq++ + a.childTasks[fmt.Sprintf("child_%d", i)] = &childTaskState{result: "r", seq: a.childSeq} + } + a.evictChildTasksLocked() + n := len(a.childTasks) + a.childMu.Unlock() + + if n > maxRetainedChildTasks { + t.Fatalf("保留子任务数=%d,超过上限 %d", n, maxRetainedChildTasks) + } + // 淘汰应保留最新的:最早的那批必须已不在 + if _, ok := a.childTasks["child_0"]; ok { + t.Fatal("淘汰应优先丢弃最旧的已完成任务") + } +} diff --git a/internal/agent/core/tooloop_test.go b/internal/agent/core/tooloop_test.go new file mode 100644 index 0000000..005b375 --- /dev/null +++ b/internal/agent/core/tooloop_test.go @@ -0,0 +1,117 @@ +package core + +import ( + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// appendPlaceholder 复刻 process() 循环顶部的补位逻辑。 +func appendPlaceholder(msgs []agentAPI.Message, replyOnly bool) []agentAPI.Message { + msgs = dropContinuationPlaceholders(msgs) + if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" { + msgs = append(msgs, agentAPI.Message{Role: "user", Content: continuationFor(replyOnly)}) + } + return msgs +} + +func countPlaceholders(msgs []agentAPI.Message) int { + n := 0 + for _, m := range msgs { + if isContinuationPlaceholder(m) { + n++ + } + } + return n +} + +// 占位是核心插入的传输层附加物,不是用户发言——它不能随轮次线性累积。 +// +// 旧实现每轮无条件追加而从不移除,跑 N 轮 prompt 里就叠了 N 条一模一样的 +// “继续”,把前缀上下文(含记忆注入)往后挤。 +func TestPlaceholderDoesNotAccumulate(t *testing.T) { + msgs := []agentAPI.Message{{Role: "user", Content: "用户请求"}} + + const rounds = 20 + for turn := 0; turn < rounds; turn++ { + msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: "调用工具"}) + msgs = append(msgs, agentAPI.Message{Role: "tool", Content: "结果"}) + + // 交替普通工具轮 / 纯发送轮,确保两种文案都参与去重。 + msgs = appendPlaceholder(msgs, turn%2 == 1) + + if n := countPlaceholders(msgs); n != 1 { + t.Fatalf("第 %d 轮后占位数=%d,期望恰好 1 条(旧实现会累积到 %d 条)", turn+1, n, turn+1) + } + } + + // 末尾那一轮是纯发送轮,留下的应是“允许收尾”的文案。 + if last := msgs[len(msgs)-1]; last.Content != replyDeliveredPlaceholder { + t.Fatalf("最后应是回复已交付的文案,实际: %q", last.Content) + } +} + +// 首轮 system/真实用户输入结尾不补位:补了会覆盖实际用户输入。 +func TestPlaceholderNotAppendedOnFirstTurn(t *testing.T) { + msgs := []agentAPI.Message{ + {Role: "system", Content: "系统说明"}, + {Role: "user", Content: "真实用户输入"}, + } + got := appendPlaceholder(msgs, false) + if len(got) != 2 { + t.Fatalf("首轮不应补位,得到 %d 条: %+v", len(got), got) + } + if got[1].Content != "真实用户输入" { + t.Fatalf("真实用户输入被覆盖: %q", got[1].Content) + } +} + +// 内容相近的真实用户消息不能被当作占位删掉。 +func TestDropOnlyExactPlaceholder(t *testing.T) { + msgs := []agentAPI.Message{ + {Role: "user", Content: continuationPlaceholder + "补充"}, + {Role: "user", Content: replyDeliveredPlaceholder + "补充"}, + {Role: "user", Content: continuationPlaceholder}, + } + got := dropContinuationPlaceholders(msgs) + if len(got) != 2 { + t.Fatalf("只应删掉精确匹配的那条,得到 %d 条: %+v", len(got), got) + } +} + +// 纯输出通道调用之后的补位不能再是「请继续」。 +// +// 异步通道的回复只能经 output_send__* 交付,所以模型「已完成回复」的形式就是 +// 一个工具调用;紧跟一句「请继续」会被读成「还要再做一步」,而能做的 +// 「一步」恰好还是再发一条消息。(生产实测:单轮 34 次发送、514 秒) +func TestContinuationForReplyDoesNotPushToContinue(t *testing.T) { + reply := continuationFor(true) + if reply == continuationPlaceholder { + t.Fatal("回复已交付后不应再补「请继续」,会驱动重复发送") + } + if !strings.Contains(reply, "纯文本") || !strings.Contains(reply, "结束") { + t.Fatalf("应明确告知可返回纯文本收尾,实际: %q", reply) + } + + if got := continuationFor(false); got != continuationPlaceholder { + t.Fatalf("普通工具轮补位应保持不变,实际: %q", got) + } +} + +// 只有真正的发送动作算「交付回复」;_help 是查询用法。 +func TestIsOutputDeliveryTool(t *testing.T) { + cases := map[string]bool{ + "output_send__qq": true, + "output_send__webui": true, + "output_send__qq_help": false, + "output_list_channels": false, + "cmd_run": false, + "qq_get_message": false, + } + for name, want := range cases { + if got := isOutputDeliveryTool(name); got != want { + t.Errorf("isOutputDeliveryTool(%q) = %v, want %v", name, got, want) + } + } +} diff --git a/internal/plugin/proc/plugin.go b/internal/plugin/proc/plugin.go index a79531f..b2d2f3e 100644 --- a/internal/plugin/proc/plugin.go +++ b/internal/plugin/proc/plugin.go @@ -345,16 +345,30 @@ func (p *Plugin) invokeOutput(channel string, args map[string]interface{}) (inte return nil, err // 真实失败上报,模型可感知并重试 } if len(raw) == 0 { - return map[string]interface{}{"status": "sent"}, nil + return map[string]interface{}{"status": "ok"}, nil } + var res map[string]interface{} - if err := json.Unmarshal(raw, &res); err != nil { - return map[string]interface{}{"status": "sent"}, nil + if err := json.Unmarshal(raw, &res); err == nil { + if _, ok := res["status"]; !ok { + res["status"] = "sent" + } + return res, nil } - if _, ok := res["status"]; !ok { - res["status"] = "sent" + + // 插件返回的是标量(如 "ok")——**原样透传,不要伪造 status**。 + // + // 为什么必须透传:核心 output.go 会把 map 结果格式化成富回执 + // ("已通过 [qq] 通道发送: map[status:sent]"),模型看到"发送成功 + + // 详情"会把这一步当成"上一步完成、继续下一步"的信号,形成 + // output_send 回声循环。插件(如 qq)刻意返回极简的 "ok" 就是为了 + // 掐断这个信号;早期实现在这里把非 map 响应替换成 {status:sent}, + // 等于把它又变回富回执——**只改插件永远修不掉这个循环**。 + var scalar interface{} + if err := json.Unmarshal(raw, &scalar); err != nil { + return map[string]interface{}{"status": "ok"}, nil } - return res, nil + return scalar, nil } // 编译期确认 Plugin 具备 registry 需要的启停形状。