Files
HomeAgent/internal/agent/core/task.go
JianFeeeee ccc2ac2d4d fix(stop): 停止按钮真正生效——停止 ≠ 空中断;鸿蒙 screensue 支持 HTML
两处鸿蒙端缺陷 + 一个跨端(WebUI/GUI/鸿蒙)的停止语义缺陷。

## 症状(实测取证)

1. **鸿蒙终止按钮按下没反应**。POST /chat/interrupt 带空 body,接口回 200
   `{"status":"interrupted"}`,但 journalctl 零中断日志、生成继续跑到自然结束。
2. **鸿蒙 screensue 不解析 HTML**,把标签当普通字符串显示。

## 根因

停止按钮走的是「空内容中断」,而 interceptLoop 有一行
`if text == "" { continue }` —— 空内容被判为「无事发生」直接丢弃。
所以停止指令从未到达调度器;接口那个 200 是不诚实的。

另查明两条会放大症状的既有问题(停止后仍在跑):
- `chatStreamWithFallback`:流式连接失败时无条件回退非流式 `Chat`。
  上下文已取消时这等于**再发一次完整请求**(停止后模型继续生成)。
- `stepLLM`:`context.Canceled` 一律 `outcomeContinue` 重跑本步。
  这是给「被更高中断抢占」用的(现场要交出去、稍后继续),
  但用户按停止是「不要了」,重跑就是停止没生效。

## 修法(按用户明确的设计)

停止 = ①立即结束当前 LLM 推理(不重试、不恢复);
②对**停止那一刻已排队**的 x 条消息,后续在 pre-action 阶段依次短路。

- scheduler:新增 `armStop`(登记快照配额并返回当时排队深度)/`takeStop`/
  `consumeCancel`。配额取快照值(停止后新到的输入不受影响),
  重复按停止取 max 不累加(两个客户端同时按不该翻倍)。
- `interceptLoop`:读 `stop` 标记。停止时 armStop + cancelCurrentLLM;
  **纯停止不再进中断队列**(旧实现把它当空中断入队,所以停完还会活)。
  带注释的停止(`/stop 换个话题`)仍走中断路径。
- `stepLLM`:取消 + `takeStop()` → 直接 `outcomeDone`(不再重跑)。
- `stepPrepare`:`consumeCancel()` 命中即在 pre-action 短路收尾。
- `chatStreamWithFallback`:以 **ctx.Err()** 为判据拒绝回退(不是「错误是不是
  Canceled」——很多 provider 用 Canceled 表示「不支持流式」,那种必须继续回退,
  否则会把探测误判成取消;这条区分是跑全量测试时才暴露的)。
- WebUI handler / CLI `/stop`:空消息时带 `stop:true`。

## 鸿蒙端

- `BridgeCaps.ets`:新增 `looksLikeHtml`(首字符 '<' + 字母开头标签名,
  避免误判 "<3" 这类文本)、`screensueHtml`、`escapeHtmlText`。
- `ScreensuePage.ets`:HTML 走 **RichText**(只解析 HTML 子集、无脚本无网络),
  纯文本仍走 Text。不用 Web 组件:agent 下发的是第三方内容,
  Web 默认带 javaScriptAccess/fileAccess,等于让远端内容在客户端执行脚本。
  注入主题前景色,避免 RichText 用系统默认色导致深色主题下黑字不可见。
- `ChatSession.ets`:`interruptChat` 改发 `{stop:true}`(含类型声明,
  ArkTS 禁止无类型对象字面量),并在本地即时复位忙态 + 提示「已停止」。

## 验证

- 新增 `stop_semantics_test.go`:停止终结任务不重试(provider 调用次数恒为 1)、
  配额是快照(x 条短路、随后新到的不受影响)、重复 arm 取 max。
- `go test ./internal/... ./cmd/...` 全绿。
- 鸿蒙 HAP 构建通过;unsigned 包已装进模拟器(signed 包受
  READ_PASTEBOARD 授权限制装不上,与既有记录一致)。
2026-09-18 11:26:12 +08:00

998 lines
38 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
// 任务状态机M1行为等价的纯重构
//
// 背景与设计见 docs/zh/input-scheduler-design.md。
//
// M1 只做一件事:把原先「一个 425 行的 process() 大函数」拆成
// **显式 step 游标 + TaskFrame**。目的不是加能力,而是让「现场」变成数据——
// 之后 M3 才能把帧存进 suspendStack 并在安全点恢复。
//
// 行为等价的判据:既有全部 agent 测试通过,且 R3/X3见设计文档 §11通过。
//
// 本文件**不引入**优先级、抢占、队列与并发;那些在 M2 起逐层加上。
//
// Step 与安全点(设计文档 §4.2
// step 与 step 之间是安全点StepToolExec工具执行与 StepPrepare 中的
// ONNX/落盘片段是**临界区**,执行中不可抢占。
//
// 与设计文档的差异:文档里的 StepBeforeOutput / StepAfterOutput / StepCommit /
// StepFinish 属于 emitResponse 与 processInput 层M1 不动它们M6 再迁入帧)。
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Step 是任务状态机的游标。
type Step int
const (
// StepPrepare 构建消息与工具表、应用中断标记、跑 pre_action 阶段。
StepPrepare Step = iota
// StepLLM 轮次顶部(中断/占位)+ LLM 调用(含 provider 回退与重试)+ post_action。
StepLLM
// StepToolBegin 取本批下一个工具,跑 before_toolcall被拒/插件不健康则跳过。
StepToolBegin
// StepToolExec 执行工具。**临界区**:副作用不可回滚,执行中不是安全点。
StepToolExec
// StepToolAfter after_toolcall 阶段、上下文裁剪、消息与事件组装、批后中断检查。
StepToolAfter
// StepTurnEnd 收尾本批并进入下一轮。
StepTurnEnd
)
// stepOutcome 是一次 step 执行的结果。
type stepOutcome int
const (
// outcomeContinue 继续执行下一个 step游标可能停在原地以表达"重跑本 step")。
outcomeContinue stepOutcome = iota
// outcomeDone 任务成功结束,响应在 frame.Response。
outcomeDone
// outcomeFailed 任务失败结束,错误在 frame.Err。
outcomeFailed
// outcomeSuspended 任务在安全点被抢占挂起帧已保存M3b 起使用)。
outcomeSuspended
)
// taskTerminal 是任务的终态种类(设计文档 §7每个任务恰有一个终态
type taskTerminal int
const (
// terminalNone 任务尚未结束,需进入 run 段。
terminalNone taskTerminal = iota
// terminalOK 正常完成(已提交上下文并回执)。
terminalOK
// terminalError 执行出错(已提交错误响应)。
terminalError
// terminalStageShortCircuit 被 on_input 阶段短路(响应已发出)。
terminalStageShortCircuit
// terminalSkipped 未进入执行:解析失败或被去重。
terminalSkipped
// terminalConsolidation 走记忆整理专用路径,已处理完毕。
terminalConsolidation
// terminalSuspended 被抢占挂起等待恢复M3b 起使用)。
terminalSuspended
)
// TaskFrame 承载一个任务在安全点之间必须存活的所有状态。
//
// 不变量(设计文档 §8.1 I3帧是**纯数据**;不得持有任何锁或资源跨越安全点。
type TaskFrame struct {
Input string
StageCtx *sdk.StageContext
// 跨轮次状态
Msgs []agentAPI.Message
Tools []interface{}
ToolsUsed []string
ToolResults []ToolResultItem
Turn int
LastBatchReplyOnly bool
// 当前工具批
PendingTools []agentAPI.ToolCall
ToolIdx int
ReplyOnly bool
ContentOnce bool
CurTool agentAPI.ToolCall
CurToolPlugin string
CurResult string
Resp *agentAPI.CompletionResponse
// Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明),
// sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景
// 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。
Scene string
turnScene memory.TurnScene
sceneDone bool
// 游标与终态
Step Step
Response string
Err error
// ---- 任务层现场(原 processInput 的局部变量)----
//
// 这些字段让帧覆盖 prepare → step… → finish 全生命周期:挂起发生在 run 段的
// 安全点,恢复后由 finish 段统一提交context.Append + emitResponse +
// emitMemoryCandidate因此挂起不会重复提交。
Evt *agentIO.InputEvent
CleanInput string
IsInterrupt bool
StartedAt time.Time
Terminal taskTerminal
// OutputChannel 是本任务的输出通道(来源通道的稳定副本)。
//
// 这是本任务通道的**唯一**来源:内核不持有"当前通道"可变状态N0 已删除
// Agent.currentOutputChannel。那类字段会被后来的任务覆盖而被打断任务
// 恢复时不重新 prepareresumeTask 只 rebase 前缀),于是两任务串台——
// 被打断任务的回复发到中断任务的通道上(见
// TestPreempt_ResumeKeepsOwnOutputChannel
OutputChannel string
// PrefixLen 是 stepPrepare 构建的**基础前缀**长度system + timeline + 用户输入)。
// 恢复时用它把「本任务自己的现场」接回重建后的前缀之上(见 rebaseFramePrefix
PrefixLen int
// InputBlocks 是本轮输入携带的多模态块;重建前缀时要重新挂回。
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}
}
// runTaskSteps 驱动状态机直到任务结束或被抢占挂起。
//
// 这是 M1 的驱动循环M3a 从 process() 抽出来,使调用方可以拿到
// outcomeSuspended 并把帧留给调度器保存。
func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
// 步数上限只是防"转移缺失导致死循环"的护栏;正常任务远达不到。
const maxSteps = 1 << 20
for i := 0; i < maxSteps; i++ {
// 安全点:只在 step 之间检查让位。临界区StepToolExec不在此列
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
//
// 先「重新求值」再判让位:临界区(或抢占冷却期)内被拦成入队的中断,
// 必须在这里重新武装——否则它只能等当前任务自然结束(设计 §4.3/§5.2)。
a.sched.rearmPending()
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
return outcomeSuspended
}
// 工具轮次硬上限(设计文档 D6在发起下一轮 LLM 前收尾。
// f.Turn 只在 stepTurnEnd 递增,所以它等于「已完成的工具批数」;
// 因此这里允许 maxToolTurns 批,而不会多跑第 maxToolTurns+1 轮。
if f.Step == StepLLM && a.maxToolTurns > 0 && f.Turn >= a.maxToolTurns {
log.Printf("[agent] 已达最大工具轮次 %dturn=%d强制收尾", a.maxToolTurns, f.Turn)
if f.Resp != nil && strings.TrimSpace(f.Resp.Content) != "" {
f.Response = f.Resp.Content
} else {
f.Response = fmt.Sprintf("[系统] 已达到最大工具轮次 %d任务中止。", a.maxToolTurns)
}
return outcomeDone
}
switch a.step(f) {
case outcomeDone:
return outcomeDone
case outcomeFailed:
return outcomeFailed
case outcomeSuspended:
return outcomeSuspended
}
}
f.Err = fmt.Errorf("agent: task step budget exhausted状态机未收敛疑似转移缺失")
return outcomeFailed
}
// process 是保留给 processConsolidation 与测试的薄壳,返回与原实现相同的四元组。
//
// 注意M3a 起**不再持 a.mu**——调度器是唯一执行者,而挂起不能持锁。
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
if a.provider == nil {
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
}
f := a.newTaskFrame(input, stageCtx)
switch a.runTaskSteps(f) {
case outcomeDone:
return f.Response, f.ToolsUsed, f.ToolResults, nil
case outcomeFailed:
return "", f.ToolsUsed, f.ToolResults, f.Err
default:
// 不该发生process() 不参与挂起(只有 runInputTask 会)。
return "", f.ToolsUsed, f.ToolResults,
fmt.Errorf("agent: task suspended outside scheduler")
}
}
// runInputTask 是一个输入任务的完整生命周期prepare → run → finish。
//
// 它是原 processInput 的全部职责,被拆成三段而不是一个大函数,目的只有一个:
// 让帧可以跨安全点被挂起——挂起后由调度器保存,恢复时接着 run 段继续,
// 而 finish 段(上下文提交与回执)只在任务真正结束时执行一次。
//
// M3a 还没有抢占,因此 outcomeSuspended 只会由 M3b 的抢占检查产生。
func (a *Agent) runInputTask(evt *agentIO.InputEvent) (*TaskFrame, stepOutcome) {
// 临界区标记由调度器 goroutine 维护,任务结束(含挂起)即清。
// interceptLoop 读它来决定“能不能取消”,因此必须是原子的。
defer a.sched.setCritical(false)
f, term := a.prepareInputTask(evt)
switch term {
case terminalSkipped, terminalStageShortCircuit, terminalConsolidation:
return nil, outcomeDone
}
out := a.runTaskSteps(f)
if out == outcomeSuspended {
f.Terminal = terminalSuspended
return f, outcomeSuspended
}
a.finishInputTask(f, out)
return f, out
}
// rebaseFramePrefix 把被挂起任务的上下文现场「加载回中断任务之上」。
//
// 语义(用户明确):
// - 中断打断时,被挂起任务自到达以来累积的全部现场(含 toolcall被保护
// - 中断在上一个任务之前的**完整状态**上开始运行(所以中断看不到本任务的部分进展);
// - 中断结束后,把被挂起任务与其现场加载回中断任务**之上**再继续——
// 即中断已提交的那段上下文留在下面(前缀),本任务自己的现场落回其上。
//
// 实现重建基础前缀system + timeline + 用户输入);由于中断结束时已把它的
// 输入/输出提交进 a.context重建出的 timeline 已含中断的效果;再把本任务
// 自己的尾部Stage 上下文 + 工具轮产物 + 占位)原样接回。
func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
if f == nil || f.PrefixLen <= 0 || f.PrefixLen > len(f.Msgs) {
return
}
tail := append([]agentAPI.Message(nil), f.Msgs[f.PrefixLen:]...)
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
// 重建会丢掉 prepare 段对尾部消息的两处改写,这里等价地补回。
if f.IsInterrupt && len(prefix) > 0 {
last := prefix[len(prefix)-1]
last.Role = "system"
last.Content = "[中断消息] " + last.Content
prefix[len(prefix)-1] = last
}
if len(f.InputBlocks) > 0 && len(prefix) > 0 {
prefix[len(prefix)-1].Blocks = f.InputBlocks
}
f.Msgs = append(prefix, tail...)
f.PrefixLen = len(prefix)
}
// prepareInputTask 执行 processInput 的前半段(去重、通道解析、阶段、裁剪、
// 输入事件落上下文)。返回终态不为 terminalNone 时调用方不得进入 run 段。
func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTerminal) {
start := time.Now()
in, ok := a.resolveInput(evt)
if !ok {
// 空输入(文本与媒体都空):没有可处理内容,但同步调用方仍在等回执。
a.emitSkippedReply(evt, "empty_input")
return nil, terminalSkipped
}
// 去重按文本做webui/GUI 断线重连会重放未确认消息。
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
// 是同一句,拿它去重会把连发的两张图误判成重复。
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
// 去重是「不处理」而不是「不回」否则同步调用方cli/clawhub 无超时)
// 会永久挂起(设计文档 §7 不变量 I5、§11.3 X2/X4
a.emitSkippedReply(evt, "duplicate")
return nil, terminalSkipped
}
// 通道只从**输入事件**推导,内核不持有"当前通道"可变状态
// (见 outputChannelOf这消除了中断任务覆盖它导致被打断任务串台的整类问题
// 进入本任务的临界区属性(记忆整理整任务不可抢占)。
// 必须在 processConsolidation 之前设置——它就在下面同步执行。
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
if evt.OutputChannel == channelConsolidation {
a.processConsolidation(evt, in.text)
return nil, terminalConsolidation
}
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
// 原始 data/url也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
//payload 里才有 data/url插件注入的是成品 block取不到原始数据。
if evt.Type == "image" || evt.Type == "audio" {
a.pendingMedia = evt.Payload
}
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
// base64 随 message 数组发给模型后就丢了。
if len(in.blocks) > 0 {
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
}
noMemory := false
if v, ok := evt.Payload["no_memory"].(bool); ok {
noMemory = v
}
if !noMemory && a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
noMemory = true
}
}
// 工具提醒/中断terminal_watch、timer 等)不是用户发言:
// 以 system 角色注入 LLM且不写入用户对话履历。
isInterrupt, _ := evt.Payload["interrupt"].(bool)
a.interruptInput = isInterrupt
if isInterrupt {
noMemory = true
}
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
stageCtx.Extra["input_source"] = evt.Source
stageCtx.Extra["output_channel"] = evt.OutputChannel
if len(in.blocks) > 0 {
stageCtx.Extra["media_blocks"] = in.blocks
stageCtx.Extra["media_type"] = in.mediaType
}
if noMemory {
stageCtx.NoMemory = true
}
a.injectSourceContext(stageCtx, evt)
if a.runStage(sdk.StageOnInput, stageCtx) {
a.emitResponse(evt, *stageCtx.Response)
return nil, terminalStageShortCircuit
}
input := stageCtx.RawMessage
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
cleanInput := input
if a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
cleanInput = chDef.Cleaner(input)
}
}
// upload_* 字段一并转发webui 的 EventRawInput 订阅方靠它们还原附件卡片。
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
if v, ok := evt.Payload[k]; ok {
rawPayload[k] = v
}
}
a.publishEvent(events.EventRawInput, rawPayload)
// 裁剪与审计统一在 memoryPass 内(日志已带 trigger
a.pruneOnInput(evt, cleanInput)
// 本轮 inputch处理表按它记账+ contextfull 检测(只有驻留子设了钩子)。
a.tableMu.Lock()
a.currentInputch = outputChannelOf(evt)
a.tableMu.Unlock()
if !isInterrupt {
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
Input: input,
})
}
f := a.newTaskFrame(input, stageCtx)
f.Evt = evt
f.CleanInput = cleanInput
f.IsInterrupt = isInterrupt
f.StartedAt = start
// 通道记进帧:恢复时用它把 agent 级字段改回来(见 TaskFrame.OutputChannel
f.OutputChannel = outputChannelOf(evt)
return f, terminalNone
}
// finishInputTask 执行 processInput 的后半段(日志、上下文提交、回执、记忆候选)。
//
// 只在任务真正结束时调用一次——这正是不变量 I5每任务恰一次终态的落点。
func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
evt := f.Evt
// pendingMedia 是「本轮」语义:任务结束即清(挂起时保留,见 runInputTask
if evt != nil && (evt.Type == "image" || evt.Type == "audio") {
a.pendingMedia = nil
}
if out == outcomeFailed {
log.Printf("[agent] process %s error: %v", evt.Type, f.Err)
resp := fmt.Sprintf("处理错误: %v", f.Err)
a.emitResponse(evt, resp)
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: f.Input, Response: resp})
f.Terminal = terminalError
return
}
// inputch 处理表:本轮**未主动写入**时由系统自动写(保证每轮必有记录)。
// 只有驻留子会用到(根 agent 的 children 为 0 时这只是几个空操作)。
a.autoRecordInputch(f)
elapsed := time.Since(f.StartedAt)
log.Printf("[agent] %s from %s → response (%dms, tools=%v)",
evt.Type, evt.Source, elapsed.Milliseconds(), f.ToolsUsed)
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的。
turnEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: f.CleanInput,
Response: f.Response,
ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults,
}
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
a.context.Append(turnEvt)
a.emitResponse(evt, f.Response)
if !f.StageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed)
}
f.Terminal = terminalOK
}
// step 执行恰好一个 step。
func (a *Agent) step(f *TaskFrame) stepOutcome {
switch f.Step {
case StepPrepare:
return a.stepPrepare(f)
case StepLLM:
return a.stepLLM(f)
case StepToolBegin:
return a.stepToolBegin(f)
case StepToolExec:
return a.stepToolExec(f)
case StepToolAfter:
return a.stepToolAfter(f)
case StepTurnEnd:
return a.stepTurnEnd(f)
default:
f.Err = fmt.Errorf("agent: unknown task step %d", f.Step)
return outcomeFailed
}
}
// stepPrepare 构建本轮任务的初始帧。
func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
f.Tools = a.buildToolDefs()
f.Msgs = a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
// 工具提醒interrupt以 system 角色注入,不让模型误认为用户发言
if a.interruptInput {
last := f.Msgs[len(f.Msgs)-1]
last.Role = "system"
last.Content = "[中断消息] " + last.Content
f.Msgs[len(f.Msgs)-1] = last
a.interruptInput = false
}
if blocks, ok := f.StageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
if len(f.Msgs) > 0 {
f.Msgs[len(f.Msgs)-1].Blocks = blocks
}
f.InputBlocks = blocks
}
// 基础前缀到此为止system + timeline + 用户输入);其后的 Stage 上下文
// 与工具轮产物都属于“本任务自己的现场”,恢复时要接回重建后的前缀之上。
f.PrefixLen = len(f.Msgs)
log.Printf("[agent] tool call loop start, max_ctx=%d target=%d fixed=%d mem=%d ctx=%d %d tools, %d events, personality=%t, docs=%d",
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
len(f.Tools), a.context.Len(),
a.personality != nil && a.personality.Content != "",
a.docStoreSize())
if a.runStage(sdk.StagePreAction, f.StageCtx) {
f.Response = *f.StageCtx.Response
return outcomeDone
}
// 停止的第二个半边(用户明确的设计):对**停止那一刻已排队**的消息,
// 依次在这里短路——不进 LLM、不执行工具直接以一条说明收尾。
// 配额是停止时的快照,消费完即止;停止之后新到的输入不受影响。
if a.sched.consumeCancel() {
log.Printf("[agent] cancelled queued task at pre-action (input=%q)", truncateStr(f.Input, 40))
f.Response = "已取消(用户停止)。"
return outcomeDone
}
if len(f.StageCtx.ContextMsgs) > 0 {
for _, m := range f.StageCtx.ContextMsgs {
role, _ := m["role"].(string)
content, _ := m["content"].(string)
if role != "" {
f.Msgs = append(f.Msgs, agentAPI.Message{Role: role, Content: content})
}
}
}
// 上下文占满检测:此刻 f.Msgs 已建好(含 system + timeline + 本轮输入)。
// 只有驻留子设了 onContextFull ⇒ 对根 agent 是 no-op。
a.checkContextFull(f)
f.Step = StepLLM
return outcomeContinue
}
// stepLLM 是轮次顶部与 LLM 调用。
//
// 取消context.Canceled 且 agent 未退出)时**留在本 step 并 Turn++**——等价于
// 原实现的 `continue`:重新排空中断、补占位、重新请求。抢占挂起将在 M3 从这里接管。
func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
f.Msgs = dropContinuationPlaceholders(f.Msgs)
if last := f.Msgs[len(f.Msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
f.Msgs = append(f.Msgs, agentAPI.Message{
Role: "user",
Content: continuationFor(f.LastBatchReplyOnly),
})
}
req := &agentAPI.CompletionRequest{
Messages: f.Msgs,
MaxTokens: 4096,
Tools: f.Tools,
ToolChoice: "auto",
DisableThinking: !a.thinkingEnabled,
}
providers := a.resolveProviders(req)
resp, llmErr := a.callLLMWithFallback(req, providers, f.OutputChannel)
if llmErr != nil {
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
if f.OutputChannel == channelConsolidation {
f.Err = fmt.Errorf("interrupted by user input")
return outcomeFailed
}
// 用户按下的「停止」:取消必须**终结本任务**,不是重跑。
//
// 默认行为(下面的 outcomeContinue 重跑本步)是给「被更高中断抢占」用的:
// 取消只是把现场交出去,稍后还要继续。而停止是用户明确的“不要了”,
// 重跑会让停止按钮看起来没反应(已实测:取消后又发了一次完整请求)。
if a.sched.takeStop() {
log.Printf("[agent] stop: LLM cancelled, task terminated without retry")
if strings.TrimSpace(f.Response) == "" {
f.Response = "已停止生成。"
}
return outcomeDone
}
f.Turn++
return outcomeContinue // 重跑 StepLLM
}
f.Err = fmt.Errorf("all %d providers failed, last error: %w", len(providers), llmErr)
return outcomeFailed
}
f.StageCtx.LLMText = resp.Content
f.StageCtx.ReasoningContent = resp.ReasoningContent
f.StageCtx.TokenUsage = map[string]int{
"prompt_tokens": resp.TokenUsage.Prompt,
"completion_tokens": resp.TokenUsage.Completion,
"total_tokens": resp.TokenUsage.Total,
}
f.StageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
for i := range f.StageCtx.ToolCalls {
if f.StageCtx.ToolCalls[i].Plugin == "" {
f.StageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(f.StageCtx.ToolCalls[i].Name)
}
}
if a.runStage(sdk.StagePostAction, f.StageCtx) {
f.Response = *f.StageCtx.Response
return outcomeDone
}
resp.Content = f.StageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(f.StageCtx.ToolCalls)
chainPayload := map[string]interface{}{
"content": resp.Content,
"reasoning": resp.ReasoningContent,
"tool_calls": resp.ToolCalls,
"phase": "intermediate",
"turn": f.Turn,
}
if resp.TokenUsage.Total > 0 {
chainPayload["usage"] = map[string]int{
"prompt": resp.TokenUsage.Prompt,
"completion": resp.TokenUsage.Completion,
"total": resp.TokenUsage.Total,
}
}
a.publishEvent(events.EventAgentLLMChain, chainPayload)
if resp.ReasoningContent != "" {
a.publishEvent(events.EventReasoning, map[string]interface{}{
"content": resp.ReasoningContent,
"channel": f.OutputChannel,
})
}
if len(resp.ToolCalls) == 0 {
f.Response = resp.Content
return outcomeDone
}
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break放在循环里统计会漏。
f.Resp = resp
f.ReplyOnly = true
for _, tc := range resp.ToolCalls {
if !isOutputDeliveryTool(tc.Name) {
f.ReplyOnly = false
break
}
}
f.ContentOnce = true
f.PendingTools = resp.ToolCalls
f.ToolIdx = 0
f.Step = StepToolBegin
return outcomeContinue
}
// stepToolBegin 取本批下一个工具;批已耗尽或发生中断则进入收尾。
func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
if f.ToolIdx >= len(f.PendingTools) {
f.Step = StepTurnEnd
return outcomeContinue
}
tc := f.PendingTools[f.ToolIdx]
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
pluginName := a.resolveToolPlugin(tc.Name)
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
if tc.RawArguments != "" {
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
}
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
f.StageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
f.StageCtx.ToolResults = nil
if a.runStage(sdk.StageBeforeToolcall, f.StageCtx) {
result := denialResultText(f.StageCtx, tc.Name)
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": pluginName,
"args": tc.Arguments,
"result": result,
"status": "denied",
"channel": f.OutputChannel,
})
f.ToolIdx++
return outcomeContinue
}
tc.Arguments = f.StageCtx.ToolCalls[0].Arguments
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
f.ToolIdx++
return outcomeContinue
}
f.CurTool = tc
f.CurToolPlugin = pluginName
f.Step = StepToolExec
return outcomeContinue
}
// denialResultText 返回「工具被插件拒绝」时交给模型的工具结果。
//
// 插件在 before_toolcall 里用 ctx.Response 写的是**拒绝理由**(为什么被拒、
// 能不能重试)。此前这里一律丢成通用文案,模型看不到原因就会反复重试同一个
// 调用——权限门精心写的"请不要重试"等于白写。有理由就用理由。
func denialResultText(ctx *sdk.StageContext, toolName string) string {
if ctx != nil && ctx.Response != nil {
if reason := strings.TrimSpace(*ctx.Response); reason != "" {
return reason
}
}
return fmt.Sprintf("工具 %s 已被插件拒绝", toolName)
}
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
// 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路),
// 而工具步不一定走到下面的召回分支,所以不能等那里再解析。
turn := a.resolveTurnScenes(f, f.CurTool.Name)
result := a.executeToolCall(f.CurTool, f.OutputChannel, turn.Keys...)
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))
f.StageCtx.ToolResults = []sdk.ToolResult{{
CallID: f.CurTool.ID, Name: f.CurTool.Name, Plugin: f.CurToolPlugin,
Success: true, Result: result,
}}
f.Step = StepToolAfter
return outcomeContinue
}
// stepToolAfter 是工具执行后的全部后处理(阶段、裁剪、消息与事件)。
func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
tc := f.CurTool
pluginName := f.CurToolPlugin
result := f.CurResult
a.runStage(sdk.StageAfterToolcall, f.StageCtx)
if len(f.StageCtx.ToolResults) > 0 {
if r, ok := f.StageCtx.ToolResults[0].Result.(string); ok {
result = r
}
}
// 工具后处理:一次相关性过程,两个**正交**声明——
// ContextPolicy=prune → 裁剪(踢出去,归档低相关 L0 事件)
// RecallPolicy=auto → 召回(取进来,注入 L2/L3 相关记忆)
// 两者共用同一份**清洗后**的 query并统一走 memoryPass同一入口、
// 同一次预算与审计)。查询向量取清洗后的有效内容,否则噪声
// ANSI/base64/JSON 包装)会把相关性打分带偏,裁错事件、召回错记忆。
var recallText string
if def := a.stageHost.ToolDef(tc.Name); def != nil {
needPrune := def.ContextPolicy == sdk.ContextPolicyPrune
needRecall := def.RecallPolicy == sdk.RecallPolicyAuto
if needPrune || needRecall {
query := a.toolOutputForQuery(tc.Name, result)
// 工具路召回的场景有两个来源:本轮输入的场面(如 chan:qq
// 与这一步工具本身(如 tool:qq_get_message。带上工具场景
// 才能让「凡是要回 QQ 消息」这类规则在该步被取回。
// 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景
scenes := sceneKeysFor(f.Evt, tc.Name)
turn := a.resolveTurnScenes(f, tc.Name)
for _, k := range turn.Keys {
scenes = append(scenes, k)
}
recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText
}
}
msgContent := ""
if f.ContentOnce {
msgContent = f.Resp.Content
f.ContentOnce = false
}
f.Msgs = append(f.Msgs, agentAPI.Message{
Role: "assistant", Content: msgContent,
ReasoningContent: f.Resp.ReasoningContent,
ToolCalls: []agentAPI.ToolCall{tc},
})
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
//
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
// 这也是插件文案一直在说的「注入后续对话」。
// 为何不能挂 tool message同一张图、同一模型、三轮实测——
// 图在 user message → 3/3 读到
// 图在 tool message → 0/3模型答「没能读到这张图」
// tool 纯文本 + 后接 user → 3/3 读到
// tool message 那轮 prompt_tokens 反而更高7967 vs 7089base64 确实
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
//
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
// 返回 200模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
var mediaMsg *agentAPI.Message
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
var blocks []agentAPI.ContentBlock
for _, b := range rawBlocks {
if cb, ok := b.(pubsdk.ContentBlock); ok {
// 跨包类型拷贝pubsdk.ContentBlock → agentAPI.ContentBlock
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
if cb.ImageURL != nil {
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
}
if cb.AudioURL != nil {
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
}
blocks = append(blocks, block)
}
}
if len(blocks) > 0 {
// 先落进 CAS无论下面走直视还是回退转写媒体本体都该进记忆。
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
// 能直视:另起一条 user message 承载媒体,并补一句来源说明。
mediaBlocks := append([]agentAPI.ContentBlock{{
Type: "text",
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
}}, native...)
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
} else if fallbackText != "" {
toolMsg.Content = result + "\n\n" + fallbackText
result = toolMsg.Content
if len(f.ToolResults) > 0 {
f.ToolResults[len(f.ToolResults)-1].Output = result
}
}
}
}
f.Msgs = append(f.Msgs, toolMsg)
if mediaMsg != nil {
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
f.Msgs = append(f.Msgs, *mediaMsg)
}
// 召回作为 system 消息挂在末尾tool/assistant 配对已完成,插入此处不断链)。
if recallText != "" {
f.Msgs = appendOrReplaceRecall(f.Msgs, recallText)
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": pluginName,
"args": tc.Arguments,
"result": result,
"status": "ok",
"channel": f.OutputChannel,
})
f.ToolIdx++
f.Step = StepToolBegin
return outcomeContinue
}
// stepTurnEnd 收尾本批并进入下一轮。
func (a *Agent) stepTurnEnd(f *TaskFrame) stepOutcome {
// 供下一轮顶部选择补位文案。
f.LastBatchReplyOnly = f.ReplyOnly
f.Turn++
f.Step = StepLLM
return outcomeContinue
}
// resolveProviders 按请求模型解析候选 provider保持原语义
func (a *Agent) resolveProviders(req *agentAPI.CompletionRequest) []agentAPI.Provider {
var providers []agentAPI.Provider
if a.providerManager != nil {
var allProviders []agentAPI.Provider
if req.Model != "" && !strings.EqualFold(req.Model, "AUTO") {
allProviders = a.providerManager.ResolveForModel(req.Model)
} else {
allProviders = a.providerManager.OrderedProviders()
}
providers = make([]agentAPI.Provider, 0, len(allProviders))
for _, p := range allProviders {
if a.providerManager.IsAvailable(p.Name()) {
providers = append(providers, p)
}
}
}
if len(providers) == 0 {
providers = []agentAPI.Provider{a.provider}
}
return providers
}
// callLLMWithFallback 在候选 provider 间回退,并把同源瞬时错误重试一次。
// 逐行等价于原 process() 内的双层循环。
func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers []agentAPI.Provider, channel string) (*agentAPI.CompletionResponse, error) {
var resp *agentAPI.CompletionResponse
var llmErr error
for pi, fbProvider := range providers {
if pi > 0 {
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
fbProvider.Name(), pi, len(providers)-1)
}
// 同源瞬时错误重试网关瞬断502/503/504/429/网络抖动)通常秒级恢复,
// 直接跳下一个 provider或直接报错会丢掉本可成功的请求。
// 凭证错误401/403与用户中断不重试。
const maxAttempts = 2
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
log.Printf("[agent] provider %q transient failure, retry %d/%d in 2s: %v",
fbProvider.Name(), attempt, maxAttempts, llmErr)
select {
case <-time.After(2 * time.Second):
case <-a.ctx.Done():
llmErr = a.ctx.Err()
}
if llmErr == nil || errors.Is(llmErr, context.Canceled) || errors.Is(llmErr, context.DeadlineExceeded) {
break
}
}
fCtx, fCancel := context.WithCancel(a.ctx)
a.llmMu.Lock()
a.cancelLLM = fCancel
a.llmMu.Unlock()
resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a, channel)
a.llmMu.Lock()
a.cancelLLM = nil
a.llmMu.Unlock()
fCancel()
if llmErr == nil {
a.providerManager.ResetAvailability(fbProvider.Name())
if fbProvider != a.provider {
a.provider = fbProvider
log.Printf("[agent] switched active provider to %q after fallback", fbProvider.Name())
}
break
}
// 用户中断:立即终止,不重试也不换 provider
if errors.Is(llmErr, context.Canceled) {
break
}
// 凭证错误:重试无意义,跳出重试循环进入 provider 标记/切换
var pe *agentAPI.ProviderError
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
break
}
// 其余错误(含 5xx/429/网络):还有重试机会则继续,否则跳出
}
if llmErr == nil {
break
}
if errors.Is(llmErr, context.Canceled) {
break
}
var pe *agentAPI.ProviderError
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
} else {
a.providerManager.MarkUnavailable(fbProvider.Name())
}
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
}
return resp, llmErr
}