fix(agent): 工具循环占位不再驱动重复发送,输出回执/子任务结果幂等

生产现象:单轮内 output_send__qq 被调用 34 次、持续 514 秒,直到 QQ 插件
自己的循环保险拒绝发送才停下(problem.md)。根因是多环节叠加,核心侧修四处:

1. 工具轮补位文案(process.go)
   通用占位「请根据以上工具结果继续。」对纯输出通道调用是错的:异步通道
   (qq/wechat)的回复只能经 output_send__* 交付,所以模型「已完成回复」的
   表达形式就是一个工具调用,紧随其后的「请继续」会被读成「还要再做一步」,
   而能做的「一步」恰好还是再发一条消息。
   改为按上一批工具的性质选文案:全部是 output_send__* 时补
   「若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。」
   同时每轮先移除旧占位再补一条,避免占位在 prompt 前缀里线性累积。
   (该占位是 zen 网关「最后一条必须是 user」的传输层附加物,HEAD 版本是
   无条件内联追加、从不移除。)

2. 输出成功回执(output.go)
   「已通过 [qq] 通道发送: map[status:sent]」这类富回执会被读成「这步成功,
   继续下一步」。成功改为只回极简标记。

3. proc 桥标量透传(internal/plugin/proc/plugin.go)
   插件返回 "ok" 时不再伪造 {status:sent} 覆盖插件真实返回值,否则只改
   output.go 不生效。

4. 子任务结果幂等(spawn.go)
   child_result 原先读到即删,而完成通知长期留在持久上下文里
   (formatMergedTimeline 每轮重新注入),第二次查询必然得到
   「不存在或已过期」这个永久失败信号,模型据此认为任务未完成而反复重试。
   改为保留结果 + delivered 标记,重复查询返回明确提示;结果按上限有界淘汰。

顺带:agent.go 去掉文档层显式向量器注入(TF-IDF 已内置为 fallback),
cmd/homed/main.go 同步 document.NewStore 的 tokenizer 参数。

测试:internal/agent/core/tooloop_test.go(5 例)、spawn_test.go(3 例)。
This commit is contained in:
JianFeeeee
2026-09-10 20:36:39 +08:00
parent b096e8ba2c
commit e218d0f100
8 changed files with 392 additions and 27 deletions

View File

@ -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)
}

View File

@ -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,

View File

@ -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)

View File

@ -15,6 +15,66 @@ import (
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// continuationPlaceholder 是工具轮之后补的 user 占位内容。
//
// zen 兼容网关要求请求最后一条必须是 userthinking 续写模式校验),工具轮
// 产出 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
}
}

View File

@ -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 {

View File

@ -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("淘汰应优先丢弃最旧的已完成任务")
}
}

View File

@ -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)
}
}
}

View File

@ -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 需要的启停形状。