Files
HomeAgent/internal/agent/core/task.go
JianFeeeee cb32032f76 feat(scheduler)!: 中断/排队两类别模型 + 插件声明 L1-L3、L4 内核独占
用户澄清推翻了早期设计的三处前提,本提交按新模型重做调度核心(行为有意变化):

1) 类别由注入 API 决定,与通道名无关
   - InjectInterrupt*                      -> TaskInterrupt(带级别,可被严格更高级中断打断)
   - InjectText*/InjectInputSync*/内核自循环 -> TaskQueued(无级别,可被任何中断打断)
   - 删除按通道名推断的 taskLevel():qq 走 InjectInterruptTextOpts,本就是中断

2) 级别只属于中断
   - 插件在 InjectOptions.Priority 声明 L1-L3(空/非法降级 L1,声明 L4 夹到 L3)
   - L4 内核独占:新增 raiseKernelInterrupt(panic / selfip);requestKernelPreempt 不夹取
   - panic 现在产生一条带 kernel 标记的 L4 中断;L4 自身 panic 不再产生新 L4(防自我放大)

3) 选择结构:四容器固定次序,删除统一比较器
   - immediate(抢占者立即运行)-> 中断队列 L4..L1 -> 栈顶(与队头比级别) -> 排队 FIFO
   - 删除 pickTaskIndex/taskBefore 与“同级 pending 优先”补丁(根因是抢占者进了队列)
   - 中断栈上界改为结构推论 = 4(= 中断级数);删除“超限转 pendingInterrupts”降级

公开 SDK(feature 分支有意新增,纯追加):InjectOptions.Priority + PriorityL1/2/3;
内核 io / proc 桥 / 插件模板同步透传。

设计稿 §2/§3/§4.1/§6.3/§9/§11/§12/§13/§15 按新模型重写。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿;
e2e(抢占-挂起-恢复)+ 压力(200 排队 + 50 中断,L1/L2/L3 轮转)通过。
2026-09-13 07:00:51 +08:00

892 lines
32 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"
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
// 游标与终态
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
// PrefixLen 是 stepPrepare 构建的**基础前缀**长度system + timeline + 用户输入)。
// 恢复时用它把「本任务自己的现场」接回重建后的前缀之上(见 rebaseFramePrefix
PrefixLen int
// InputBlocks 是本轮输入携带的多模态块;重建前缀时要重新挂回。
InputBlocks []agentAPI.ContentBlock
}
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 置位、而本循环是唯一读帧者。
if !a.inCriticalSection() && 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.buildMemoryContext(f.Input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
prefix := a.buildMessages(sysPrompt, f.Input, budget.ContextTokens)
// 重建会丢掉 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
}
a.currentOutputChannel = evt.OutputChannel
if a.currentOutputChannel == "" {
a.currentOutputChannel = evt.Source
}
// 进入本任务的临界区属性(记忆整理整任务不可抢占)。
// 必须在 processConsolidation 之前设置——它就在下面同步执行。
a.sched.setCritical(a.inCriticalSection())
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)
archived := a.pruneOnInput(evt, cleanInput)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
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
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
}
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.buildMemoryContext(f.Input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
f.Tools = a.buildToolDefs()
f.Msgs = a.buildMessages(sysPrompt, f.Input, budget.ContextTokens)
// 工具提醒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
}
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.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)
if llmErr != nil {
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
if a.currentOutputChannel == "_consolidation_" {
f.Err = fmt.Errorf("interrupted by user input")
return outcomeFailed
}
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": a.currentOutputChannel,
})
}
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 := fmt.Sprintf("工具 %s 已被插件拒绝", 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": a.currentOutputChannel,
})
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
}
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
result := a.executeToolCall(f.CurTool)
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 工具调用后执行上下文裁剪§13.8
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
if a.context != nil {
topK := a.maxContextSize - 1
if topK < 1 {
topK = 1
}
// 查询向量取**清洗后**的有效内容否则噪声ANSI/base64/JSON 包装)
// 会把相关性打分带偏,裁掉本该保留的事件。
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
}
}
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)
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": pluginName,
"args": tc.Arguments,
"result": result,
"status": "ok",
"channel": a.currentOutputChannel,
})
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) (*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)
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
}