From f2ec46480ec83dbfe1442e35bffeb3ade6150a29 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 08:59:15 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core)!:=20N0=20=E6=97=A0=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=8C=96=20=E2=80=94=E2=80=94=20=E5=88=A0=E9=99=A4=20?= =?UTF-8?q?Agent.currentOutputChannel=EF=BC=8C=E9=80=9A=E9=81=93=E5=8F=AA?= =?UTF-8?q?=E8=B7=9F=E8=BE=93=E5=85=A5=E4=BA=8B=E4=BB=B6/=E5=B8=A7?= =?UTF-8?q?=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 驻留式子 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(只剩描述历史的注释) --- internal/agent/core/agent.go | 1 - internal/agent/core/distill.go | 1 - internal/agent/core/eventloop.go | 11 +-- internal/agent/core/process.go | 12 +-- internal/agent/core/scheduler.go | 9 -- .../core/scheduler_channel_routing_test.go | 91 +++++++++++++++++++ .../agent/core/scheduler_critical_test.go | 24 ++++- internal/agent/core/spawn.go | 11 +-- internal/agent/core/stage.go | 7 +- internal/agent/core/stream_accumulate_test.go | 4 +- internal/agent/core/stream_index_test.go | 2 +- internal/agent/core/task.go | 58 +++++++++--- internal/agent/core/toolcall.go | 8 +- internal/agent/core/tooldefs.go | 4 +- 14 files changed, 183 insertions(+), 60 deletions(-) create mode 100644 internal/agent/core/scheduler_channel_routing_test.go diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index f2696ef..a9561b1 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -84,7 +84,6 @@ type Agent struct { maxContextSize int // 当前请求的输出通道(mutex 保护,process() 内独占) - currentOutputChannel string // 阶段管道:插件消息流编辑 stageHost *StageHost diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 08dedae..38d2927 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -495,7 +495,6 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) { start := time.Now() - a.currentOutputChannel = "_consolidation_" stageCtx := a.stageCtxFromInput(input, evt.Source, "") stageCtx.Extra["output_channel"] = evt.OutputChannel diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index c2f048c..26e1548 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -310,21 +310,16 @@ func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) { } func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) { + // 通道一律从**输入事件**推导(内核不持有"当前通道")。 + ch := outputChannelOf(evt) stageCtx := &sdk.StageContext{ FinalText: response, Phase: sdk.StageBeforeOutput, + Extra: map[string]interface{}{"output_channel": ch}, } a.runStage(sdk.StageBeforeOutput, stageCtx) response = stageCtx.FinalText - ch := a.currentOutputChannel - if ch == "" { - ch = evt.OutputChannel - } - if ch == "" { - ch = evt.Source - } - payload := map[string]interface{}{ "content": response, "request_id": evt.RequestID, diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 23c261b..267498c 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -109,14 +109,14 @@ func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message { // // 超时收益:首包 ~1-3s 到达即建立活性,后续只要 token 在流动就不会触发 // 空闲超时;总生成时长不再受限於 180s 整体超时。 -func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent) (*agentAPI.CompletionResponse, error) { +func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent, channel string) (*agentAPI.CompletionResponse, error) { ch, err := p.ChatStream(ctx, req) if err != nil { log.Printf("[agent] stream connect failed (%v), falling back to non-stream chat", err) return p.Chat(ctx, req) } - resp, accErr := accumulateStream(ctx, ch, a) + resp, accErr := accumulateStream(ctx, ch, a, channel) // 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致: // 被 cancel 时丢弃已收内容返回 err),让 process() 的 continue 分支 @@ -127,7 +127,7 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent if a != nil { a.publishEvent(events.EventContentDelta, map[string]interface{}{ "content": "", - "channel": a.currentOutputChannel, + "channel": channel, "reset": true, }) } @@ -157,7 +157,7 @@ type toolCallAcc struct { // accumulateStream 消费 chunk channel,累积为完整 CompletionResponse, // 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。 -func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent) (*agentAPI.CompletionResponse, error) { +func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent, channel string) (*agentAPI.CompletionResponse, error) { resp := &agentAPI.CompletionResponse{ ToolCalls: make([]agentAPI.ToolCall, 0), } @@ -209,7 +209,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag if a != nil { a.publishEvent(events.EventReasoningDelta, map[string]interface{}{ "content": ck.ReasoningContent, - "channel": a.currentOutputChannel, + "channel": channel, }) } } @@ -218,7 +218,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag if a != nil { a.publishEvent(events.EventContentDelta, map[string]interface{}{ "content": ck.Content, - "channel": a.currentOutputChannel, + "channel": channel, }) } } diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index 3b7113e..4015183 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -714,15 +714,6 @@ func parseInterruptLevel(s string) (Level, bool) { } } -// inCriticalSection 报告运行任务是否处于不可抢占区。 -// -// M3b 只处理「整个任务不可抢占」的情形(记忆整理)。工具执行、ONNX、 -// CAS 落盘属于**单步**临界区——它们由「只在 step 之间检查让位」天然保护, -// 不需要在这里列(M4 会把清单显式化)。 -func (a *Agent) inCriticalSection() bool { - return a.currentOutputChannel == channelConsolidation -} - // newInputTask 把一个**排队输入**包装成任务(无级别)。 func newInputTask(evt *agentIO.InputEvent) *Task { return &Task{Class: TaskQueued, Kind: TaskKindInput, Event: evt, EnqueuedAt: time.Now()} diff --git a/internal/agent/core/scheduler_channel_routing_test.go b/internal/agent/core/scheduler_channel_routing_test.go new file mode 100644 index 0000000..ec7bcee --- /dev/null +++ b/internal/agent/core/scheduler_channel_routing_test.go @@ -0,0 +1,91 @@ +package core + +// 抢占场景下的**输出通道路由**:被打断任务恢复后,回复必须回到它自己的输出通道。 +// +// 回归判据(做驻留式子 agent 前必须成立):**内核不持有"当前通道"可变状态**。 +// 曾经有 agent 级字段 a.currentOutputChannel:只在 prepare 段写入,而被打断任务 +// 恢复时不重新 prepare(resumeTask 只 rebase 前缀),于是中断任务 prepare 时把它 +// 覆盖成自己的通道,被恢复的任务再把回复发到**中断任务的通道**上——两任务串台。 +// N0 已删除该字段:通道一律从输入事件/帧推导(outputChannelOf / f.OutputChannel)。 +// +// 每任务回执(evt.ResponseCh,Target=evt.Source)不受影响,所以既有测试全绿; +// 但按通道投递(OutputEvent.OutputChannel / events.EventAgentOutput 的 channel) +// 是插件渲染给用户的路径,它会串。 + +import ( + "testing" + "time" +) + +func TestPreempt_ResumeKeepsOwnOutputChannel(t *testing.T) { + sp := newPreemptProvider("intr-done", "low-done") + a := newPreemptAgent(t, sp) + + // 排队任务,来源与输出通道都是 qq。 + lowEvt, lowCh := textEvent("qq", "低优先级任务") + lowTask := newInputTask(lowEvt) + if !a.sched.enqueue(lowTask) { + t.Fatal("入队失败") + } + if lowEvt.OutputChannel != "qq" { + t.Fatalf("前置条件不成立:OutputChannel=%q", lowEvt.OutputChannel) + } + + lt, _, kind := a.sched.nextRef() + if kind != nextReady { + t.Fatalf("应取到排队任务,kind=%v", kind) + } + done := make(chan struct{}) + go func() { a.executeNewTask(lt); close(done) }() + + select { + case <-sp.entered: + case <-time.After(3 * time.Second): + t.Fatal("provider 未被调用") + } + + // cli 中断抢占(L3):任务被挂起。 + intrEvt, intrCh := textEvent("cli", "紧急打断") + intrEvt.Payload["interrupt"] = true + if !a.sched.requestPreempt(intrEvt, LevelInteractive) { + t.Fatal("L3 中断应能抢占排队任务") + } + a.cancelCurrentLLM() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("被打断任务未挂起") + } + + // 中断任务先运行(它会 prepare,通道是 cli)。 + it, _, k := a.sched.nextRef() + if k != nextImmediate { + t.Fatalf("应取到立即运行的中断,kind=%v", k) + } + a.executeNewTask(it) + if len(intrCh) != 1 { + t.Fatalf("中断任务应回执一次,实际 %d", len(intrCh)) + } + // 注意:这里**刻意**不再有任何"内核当前通道"可断言 —— 该字段已删除, + // 通道只跟着输入事件与帧走。下面断言的就是这个性质本身。 + + // 恢复被抢占任务:它不重新 prepare,只能靠帧里记着自己的通道。 + rt, rf, k2 := a.sched.nextRef() + if k2 != nextSuspended { + t.Fatalf("应恢复被抢占任务,kind=%v", k2) + } + a.resumeTask(rt, rf) + + select { + case out := <-lowCh: + if out.OutputChannel != "qq" { + t.Fatalf("被打断任务恢复后的输出通道=%q,期望 qq —— 被中断任务的通道覆盖了 agent 级字段(两任务串台)", + out.OutputChannel) + } + if out.Target != "qq" { + t.Fatalf("回执 Target=%q,期望 qq", out.Target) + } + case <-time.After(3 * time.Second): + t.Fatal("被恢复任务未回执") + } +} diff --git a/internal/agent/core/scheduler_critical_test.go b/internal/agent/core/scheduler_critical_test.go index 0ecf37c..13a6ca9 100644 --- a/internal/agent/core/scheduler_critical_test.go +++ b/internal/agent/core/scheduler_critical_test.go @@ -95,14 +95,30 @@ func TestPreempt_DeferredDuringToolExec(t *testing.T) { func TestCriticalSection_ConsolidationMarked(t *testing.T) { a := newPreemptAgent(t, newPreemptProvider()) - a.currentOutputChannel = "cli" - if a.inCriticalSection() { + // 判定:只有记忆整理通道是整任务临界区。 + if isCriticalChannel("cli") { t.Fatal("普通通道不应被判为临界区") } - a.currentOutputChannel = channelConsolidation - if !a.inCriticalSection() { + 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) + } + } } // 新语义:没有抢占时,同批的多个工具必须全部执行——不再有「中断放弃剩余批」。 diff --git a/internal/agent/core/spawn.go b/internal/agent/core/spawn.go index ea013d4..f3b1664 100644 --- a/internal/agent/core/spawn.go +++ b/internal/agent/core/spawn.go @@ -45,7 +45,7 @@ func (a *Agent) evictChildTasksLocked() { } } -func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { +func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string { task, _ := tc.Arguments["task"].(string) if task == "" { if b, _ := json.Marshal(tc.Arguments); len(b) > 2 { @@ -69,9 +69,8 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string { taskID := fmt.Sprintf("child_%d", a.childNextID) a.childMu.Unlock() - // 捕获父 Agent 当前输出通道:子任务完成通知需回到发起对话的通道, - // 让父 Agent 正常感知并可回复用户(而非走无记忆整理路径丢失通知)。 - parentChannel := a.currentOutputChannel + // parentChannel 由调用方(任务帧)传入:子任务完成通知要回到**发起这次 + // spawn 的那个任务**的通道,而不是"内核当前通道"(那个概念已删除)。 if parentChannel == "" || parentChannel == channelConsolidation { parentChannel = "cli" } @@ -150,7 +149,7 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns case ct.Name == "spawn_child" || ct.Name == "plgreload": result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name) default: - result = a.executeToolCall(ct) + result = a.executeToolCall(ct, parentChannel) } msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}}) msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result}) @@ -249,5 +248,3 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name) } } - - diff --git a/internal/agent/core/stage.go b/internal/agent/core/stage.go index dda837b..aca4f57 100644 --- a/internal/agent/core/stage.go +++ b/internal/agent/core/stage.go @@ -12,9 +12,14 @@ import ( ) func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool { + // 通道从 stage ctx 上取(由发起方写入)——内核不持有"当前通道"。 + ch := "" + if ctx != nil && ctx.Extra != nil { + ch, _ = ctx.Extra["output_channel"].(string) + } payload := map[string]interface{}{ "phase": string(stage), - "channel": a.currentOutputChannel, + "channel": ch, } if ctx != nil && len(ctx.ToolCalls) > 0 { payload["tool"] = ctx.ToolCalls[0].Name diff --git a/internal/agent/core/stream_accumulate_test.go b/internal/agent/core/stream_accumulate_test.go index ba6838c..8c73037 100644 --- a/internal/agent/core/stream_accumulate_test.go +++ b/internal/agent/core/stream_accumulate_test.go @@ -26,7 +26,7 @@ func TestAccumulateStreamToolCalls(t *testing.T) { close(ch) }() - resp, err := accumulateStream(context.Background(), ch, nil) + resp, err := accumulateStream(context.Background(), ch, nil, "cli") if err != nil { t.Fatalf("accumulateStream: %v", err) } @@ -56,7 +56,7 @@ func TestAccumulateStreamContent(t *testing.T) { ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"} close(ch) }() - resp, err := accumulateStream(context.Background(), ch, nil) + resp, err := accumulateStream(context.Background(), ch, nil, "cli") if err != nil { t.Fatalf("accumulateStream: %v", err) } diff --git a/internal/agent/core/stream_index_test.go b/internal/agent/core/stream_index_test.go index 0e4de4a..2ca199c 100644 --- a/internal/agent/core/stream_index_test.go +++ b/internal/agent/core/stream_index_test.go @@ -41,7 +41,7 @@ func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) { } close(ch) - resp, err := accumulateStream(ctx, ch, nil) + resp, err := accumulateStream(ctx, ch, nil, "cli") if err != nil { t.Fatalf("accumulateStream: %v", err) } diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 1d02eeb..981ceff 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -126,6 +126,14 @@ type TaskFrame struct { IsInterrupt bool StartedAt time.Time Terminal taskTerminal + // OutputChannel 是本任务的输出通道(来源通道的稳定副本)。 + // + // 这是本任务通道的**唯一**来源:内核不持有"当前通道"可变状态(N0 已删除 + // Agent.currentOutputChannel)。那类字段会被后来的任务覆盖,而被打断任务 + // 恢复时不重新 prepare(resumeTask 只 rebase 前缀),于是两任务串台—— + // 被打断任务的回复发到中断任务的通道上(见 + // TestPreempt_ResumeKeepsOwnOutputChannel)。 + OutputChannel string // PrefixLen 是 stepPrepare 构建的**基础前缀**长度(system + timeline + 用户输入)。 // 恢复时用它把「本任务自己的现场」接回重建后的前缀之上(见 rebaseFramePrefix)。 @@ -134,6 +142,28 @@ type TaskFrame struct { InputBlocks []agentAPI.ContentBlock } +// outputChannelOf 从**输入事件**推导本次输出应走的通道。 +// +// 内核不持有"当前通道"可变状态:那类字段会被后来的任务(中断任务)覆盖, +// 使被打断任务恢复后的提示词/事件标签串台。通道只跟着事件与帧走。 +func outputChannelOf(evt *agentIO.InputEvent) string { + if evt == nil { + return "" + } + if evt.OutputChannel != "" { + return evt.OutputChannel + } + return evt.Source +} + +// isCriticalChannel 报告某个通道是否是**整任务不可抢占**的临界区。 +// +// 目前只有 `_consolidation_`(记忆整理直接改图库)。工具执行/ONNX/CAS 属于 +// **单步**临界区,由"只在 step 之间检查让位"天然保护,不在这里列。 +func isCriticalChannel(channel string) bool { + return channel == channelConsolidation +} + func (a *Agent) newTaskFrame(input string, stageCtx *sdk.StageContext) *TaskFrame { return &TaskFrame{Input: input, StageCtx: stageCtx, Step: StepPrepare} } @@ -148,7 +178,7 @@ func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome { for i := 0; i < maxSteps; i++ { // 安全点:只在 step 之间检查让位。临界区(StepToolExec)不在此列, // 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。 - if !a.inCriticalSection() && a.sched.preemptGrantedFor() && a.sched.canSuspend() { + if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() { return outcomeSuspended } // 工具轮次硬上限(设计文档 D6):在发起下一轮 LLM 前收尾。 @@ -283,13 +313,11 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi return nil, terminalSkipped } - a.currentOutputChannel = evt.OutputChannel - if a.currentOutputChannel == "" { - a.currentOutputChannel = evt.Source - } + // 通道只从**输入事件**推导,内核不持有"当前通道"可变状态 + // (见 outputChannelOf;这消除了中断任务覆盖它导致被打断任务串台的整类问题)。 // 进入本任务的临界区属性(记忆整理整任务不可抢占)。 // 必须在 processConsolidation 之前设置——它就在下面同步执行。 - a.sched.setCritical(a.inCriticalSection()) + a.sched.setCritical(isCriticalChannel(outputChannelOf(evt))) if evt.OutputChannel == channelConsolidation { a.processConsolidation(evt, in.text) @@ -381,6 +409,8 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi f.CleanInput = cleanInput f.IsInterrupt = isInterrupt f.StartedAt = start + // 通道记进帧:恢复时用它把 agent 级字段改回来(见 TaskFrame.OutputChannel)。 + f.OutputChannel = outputChannelOf(evt) return f, terminalNone } @@ -525,11 +555,11 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { } providers := a.resolveProviders(req) - resp, llmErr := a.callLLMWithFallback(req, providers) + resp, llmErr := a.callLLMWithFallback(req, providers, f.OutputChannel) if llmErr != nil { if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil { - if a.currentOutputChannel == "_consolidation_" { + if f.OutputChannel == channelConsolidation { f.Err = fmt.Errorf("interrupted by user input") return outcomeFailed } @@ -579,7 +609,7 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { if resp.ReasoningContent != "" { a.publishEvent(events.EventReasoning, map[string]interface{}{ "content": resp.ReasoningContent, - "channel": a.currentOutputChannel, + "channel": f.OutputChannel, }) } @@ -633,7 +663,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome { "args": tc.Arguments, "result": result, "status": "denied", - "channel": a.currentOutputChannel, + "channel": f.OutputChannel, }) f.ToolIdx++ return outcomeContinue @@ -657,7 +687,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome { // stepToolExec 执行工具。**临界区**:见设计文档 §4.3。 func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome { - result := a.executeToolCall(f.CurTool) + result := a.executeToolCall(f.CurTool, f.OutputChannel) f.CurResult = result f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result}) log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100)) @@ -768,7 +798,7 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { "args": tc.Arguments, "result": result, "status": "ok", - "channel": a.currentOutputChannel, + "channel": f.OutputChannel, }) f.ToolIdx++ @@ -810,7 +840,7 @@ func (a *Agent) resolveProviders(req *agentAPI.CompletionRequest) []agentAPI.Pro // callLLMWithFallback 在候选 provider 间回退,并把同源瞬时错误重试一次。 // 逐行等价于原 process() 内的双层循环。 -func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers []agentAPI.Provider) (*agentAPI.CompletionResponse, error) { +func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers []agentAPI.Provider, channel string) (*agentAPI.CompletionResponse, error) { var resp *agentAPI.CompletionResponse var llmErr error @@ -843,7 +873,7 @@ func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers [ a.cancelLLM = fCancel a.llmMu.Unlock() - resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a) + resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a, channel) a.llmMu.Lock() a.cancelLLM = nil diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index b39ee84..1e06d2e 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -13,7 +13,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" ) -func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) { +func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret string) { defer func() { if r := recover(); r != nil { stack := debug.Stack() @@ -31,7 +31,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) { done := make(chan string, 1) go func() { - done <- a.executeToolCallInner(tc) + done <- a.executeToolCallInner(tc, channel) }() select { @@ -43,7 +43,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) { } } -func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string { +func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) string { switch { case tc.Name == "persona_set": return a.executePersonaTool(tc) @@ -67,7 +67,7 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string { pluginName, _ := tc.Arguments["plugin_name"].(string) return a.executeGetPluginTools(pluginName) case tc.Name == "spawn_child": - return a.executeSpawnChild(tc) + return a.executeSpawnChild(tc, channel) case tc.Name == "child_result": return a.executeChildResultTool(tc) case strings.HasPrefix(tc.Name, "llm_"): diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 4037a4a..2a0ecda 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -68,8 +68,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。" prompt += "\n\n【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n" - prompt += "- 当前输入来自哪个通道,就优先用哪个通道回复;不要串到其他通道(除非用户明确要求)。\n" - prompt += "- 当前输入来源通道(即对话发生的通道)是:" + a.currentOutputChannel + "。对应输出门工具是 output_send__{该通道名}。\n" + prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n" + prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n" prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n" prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n" prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"