Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
362
backend/services/agent/node/agent_nodes.go
Normal file
362
backend/services/agent/node/agent_nodes.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// AgentNodes 负责把 graph 层的节点调用统一转成 node 层真正的执行入口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只做参数转发、依赖注入和状态落盘,不承载业务决策。
|
||||
// 2. 各节点真正的执行逻辑仍在对应的 RunXXXNode 内。
|
||||
// 3. 节点成功后统一保存快照,方便断线恢复。
|
||||
type AgentNodes struct{}
|
||||
|
||||
// NewAgentNodes 创建通用节点容器。
|
||||
func NewAgentNodes() *AgentNodes {
|
||||
return &AgentNodes{}
|
||||
}
|
||||
|
||||
// Chat 负责把 graph 的 chat 节点请求转给 RunChatNode。
|
||||
func (n *AgentNodes) Chat(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("chat node: state is nil")
|
||||
}
|
||||
|
||||
// 1. Chat 阶段只负责路由与纯对话,不需要看到工具目录,避免能力细节干扰判断。
|
||||
st.EnsureConversationContext().SetToolSchemas(nil)
|
||||
|
||||
if err := RunChatNode(ctx, ChatNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
ConfirmAction: st.Request.ConfirmAction,
|
||||
ResumeInteractionID: st.Request.ResumeInteractionID,
|
||||
Client: st.Deps.ResolveChatClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Confirm 负责把 graph 的 confirm 节点请求转给 RunConfirmNode。
|
||||
func (n *AgentNodes) Confirm(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("confirm node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunConfirmNode(ctx, ConfirmNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Plan 负责把 graph 的 plan 节点请求转给 RunPlanNode。
|
||||
func (n *AgentNodes) Plan(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("plan node: state is nil")
|
||||
}
|
||||
|
||||
// 等待后端记忆检索完成,再把最新结果注入上下文。
|
||||
ensureFreshMemory(st)
|
||||
|
||||
if err := RunPlanNode(ctx, PlanNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
Client: st.Deps.ResolvePlanClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
ResumeNode: "plan",
|
||||
AlwaysExecute: st.Request.AlwaysExecute,
|
||||
ThinkingEnabled: st.Deps.ThinkingPlan,
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// RoughBuild 负责把 graph 的 rough_build 节点请求转给 RunRoughBuildNode。
|
||||
func (n *AgentNodes) RoughBuild(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("rough_build node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunRoughBuildNode(ctx, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Interrupt 负责把 graph 的 interrupt 节点请求转给 RunInterruptNode。
|
||||
func (n *AgentNodes) Interrupt(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("interrupt node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunInterruptNode(ctx, InterruptNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Execute 负责把 graph 的 execute 节点请求转给 RunExecuteNode。
|
||||
func (n *AgentNodes) Execute(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("execute node: state is nil")
|
||||
}
|
||||
|
||||
// 1. 首次进入时按需加载日程状态,后续轮次复用内存状态。
|
||||
var scheduleState *schedule.ScheduleState
|
||||
if ss, loadErr := st.EnsureScheduleState(ctx); loadErr != nil {
|
||||
return nil, fmt.Errorf("execute node: 加载日程状态失败: %w", loadErr)
|
||||
} else if ss != nil {
|
||||
scheduleState = ss
|
||||
}
|
||||
|
||||
// 2. 把工具 schema 注入上下文,供 LLM 看到真实工具边界。
|
||||
if st.Deps.ToolRegistry != nil {
|
||||
activeDomain := ""
|
||||
var activePacks []string
|
||||
if flowState := st.EnsureFlowState(); flowState != nil {
|
||||
activeDomain, activePacks = resolveEffectiveExecuteToolDomain(flowState)
|
||||
}
|
||||
schemas := st.Deps.ToolRegistry.SchemasForActiveDomain(activeDomain, activePacks)
|
||||
if flowState := st.EnsureFlowState(); flowState != nil && flowState.ActiveOptimizeOnly {
|
||||
schemas = agenttools.FilterSchemasForActiveOptimize(schemas)
|
||||
}
|
||||
toolSchemas := make([]agentmodel.ToolSchemaContext, len(schemas))
|
||||
for i, s := range schemas {
|
||||
toolSchemas[i] = agentmodel.ToolSchemaContext{
|
||||
Name: s.Name,
|
||||
Desc: s.Desc,
|
||||
SchemaText: s.SchemaText,
|
||||
}
|
||||
}
|
||||
st.EnsureConversationContext().SetToolSchemas(toolSchemas)
|
||||
}
|
||||
|
||||
// 3. 等待后端记忆检索结果,再把最新结果注入上下文。
|
||||
ensureFreshMemory(st)
|
||||
|
||||
if err := RunExecuteNode(ctx, ExecuteNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
Client: st.Deps.ResolveExecuteClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
ResumeNode: "execute",
|
||||
ToolRegistry: st.Deps.ToolRegistry,
|
||||
ScheduleState: scheduleState,
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
WriteSchedulePreview: st.Deps.WriteSchedulePreview,
|
||||
OriginalScheduleState: st.OriginalScheduleState,
|
||||
AlwaysExecute: st.Request.AlwaysExecute,
|
||||
ThinkingEnabled: st.Deps.ThinkingExecute,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// QuickTask 负责把 graph 的 quick_task 节点请求转给 RunQuickTaskNode。
|
||||
func (n *AgentNodes) QuickTask(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("quick_task node: state is nil")
|
||||
}
|
||||
|
||||
// QuickTask 不需要工具目录,直接复用 ChatClient。
|
||||
st.EnsureConversationContext().SetToolSchemas(nil)
|
||||
|
||||
if err := RunQuickTaskNode(ctx, QuickTaskNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
Client: st.Deps.ResolveChatClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
QuickTaskDeps: st.Deps.QuickTaskDeps,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Deliver 负责把 graph 的 deliver 节点请求转给 RunDeliverNode。
|
||||
func (n *AgentNodes) Deliver(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("deliver node: state is nil")
|
||||
}
|
||||
|
||||
// 1. Deliver 只做最终收口总结,不需要工具目录,避免无关能力信息污染总结。
|
||||
st.EnsureConversationContext().SetToolSchemas(nil)
|
||||
|
||||
if err := RunDeliverNode(ctx, DeliverNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
Client: st.Deps.ResolveDeliverClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
ThinkingEnabled: st.Deps.ThinkingDeliver,
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 只有真正完成时才写入排程预览,避免中间态污染前端展示。
|
||||
if st.Deps.WriteSchedulePreview != nil && st.ScheduleState != nil {
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState != nil && flowState.IsCompleted() {
|
||||
if err := st.Deps.WriteSchedulePreview(ctx, st.ScheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil {
|
||||
log.Printf("[WARN] deliver: 写入排程预览缓存失败 chat=%s: %v", flowState.ConversationID, err)
|
||||
}
|
||||
} else if flowState != nil {
|
||||
log.Printf("[DEBUG] deliver: skip schedule preview chat=%s terminal_status=%s", flowState.ConversationID, flowState.TerminalStatus())
|
||||
}
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ensureFreshMemory 等待后端记忆检索完成,并把最新结果写入 ConversationContext。
|
||||
//
|
||||
// 1. 只在首次调用时等待 channel,后续调用直接跳过。
|
||||
// 2. 超时后保留原有上下文,不额外覆盖。
|
||||
// 3. 记忆为空时也不做额外写入,避免污染 prompt。
|
||||
func ensureFreshMemory(st *agentmodel.AgentGraphState) {
|
||||
if st == nil || st.Deps.MemoryConsumed || st.Deps.MemoryFuture == nil {
|
||||
return
|
||||
}
|
||||
st.Deps.MemoryConsumed = true
|
||||
|
||||
select {
|
||||
case content := <-st.Deps.MemoryFuture:
|
||||
if strings.TrimSpace(content) != "" {
|
||||
st.EnsureConversationContext().UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: agentmodel.MemoryContextBlockKey,
|
||||
Title: agentmodel.MemoryContextBlockTitle,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
case <-time.After(agentmodel.MemoryFreshTimeout):
|
||||
// 超时后保留原有上下文即可。
|
||||
}
|
||||
}
|
||||
|
||||
// saveAgentState 在节点成功执行后保存运行快照。
|
||||
func saveAgentState(ctx context.Context, st *agentmodel.AgentGraphState) {
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
store := st.Deps.StateStore
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeState := st.EnsureRuntimeState()
|
||||
if runtimeState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if flowState == nil || flowState.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot := &agentmodel.AgentStateSnapshot{
|
||||
RuntimeState: runtimeState,
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
ScheduleState: st.ScheduleState.Clone(),
|
||||
OriginalScheduleState: st.OriginalScheduleState.Clone(),
|
||||
}
|
||||
|
||||
_ = store.Save(ctx, flowState.ConversationID, snapshot)
|
||||
}
|
||||
|
||||
// deleteAgentState 在任务完成后删除运行快照。
|
||||
func deleteAgentState(ctx context.Context, st *agentmodel.AgentGraphState) {
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
store := st.Deps.StateStore
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeState := st.EnsureRuntimeState()
|
||||
if runtimeState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if flowState == nil || flowState.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_ = store.Delete(ctx, flowState.ConversationID)
|
||||
}
|
||||
|
||||
// resolveEffectiveExecuteToolDomain 计算“本轮 execute 真正应看到”的工具域快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先读取 PendingContextHook,让首轮 execute 的 schema 注入与即将生效的规则包保持一致;
|
||||
// 2. 只做只读推导,不消费 PendingContextHook,真正的状态更新仍由 RunExecuteNode 统一处理;
|
||||
// 3. hook 非法或为空时,回退到已持久化的 ActiveToolDomain/ActiveToolPacks,保持历史链路兼容。
|
||||
func resolveEffectiveExecuteToolDomain(flowState *agentmodel.CommonState) (string, []string) {
|
||||
if flowState == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 1. 若 plan / rough_build 已写入待生效 hook,则首轮 execute 必须优先按它推导工具域,
|
||||
// 否则 prompt 里的规则包和注入的工具 schema 会错位,模型第一轮看不到该用的工具。
|
||||
if hook := flowState.PendingContextHook; hook != nil {
|
||||
domain := agenttools.NormalizeToolDomain(hook.Domain)
|
||||
if domain != "" {
|
||||
return domain, agenttools.ResolveEffectiveToolPacks(domain, hook.Packs)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. hook 不可用时回退到当前已激活域,保持老链路与恢复链路的行为不变。
|
||||
domain := agenttools.NormalizeToolDomain(flowState.ActiveToolDomain)
|
||||
if domain == "" {
|
||||
return "", nil
|
||||
}
|
||||
return domain, agenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
}
|
||||
908
backend/services/agent/node/chat.go
Normal file
908
backend/services/agent/node/chat.go
Normal file
@@ -0,0 +1,908 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
chatStageName = "chat"
|
||||
chatStatusBlockID = "chat.status"
|
||||
chatSpeakBlockID = "chat.speak"
|
||||
// chatHistoryKindKey 用于在 history 中打运行态标记,供 prompt 层做上下文分层。
|
||||
chatHistoryKindKey = "newagent_history_kind"
|
||||
// chatHistoryKindExecuteLoopClosed 表示"上一轮 execute loop 已正常收口"。
|
||||
// prompt 侧会据此把旧 loop 归档到 msg1,而不是继续占用 msg2 窗口。
|
||||
chatHistoryKindExecuteLoopClosed = "execute_loop_closed"
|
||||
)
|
||||
|
||||
type reorderPreference int
|
||||
|
||||
const (
|
||||
reorderUnknown reorderPreference = iota
|
||||
reorderAllow
|
||||
reorderDisallow
|
||||
)
|
||||
|
||||
// ChatNodeInput 描述聊天节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载"本轮 chat"需要的输入,不负责持久化;
|
||||
// 2. RuntimeState 提供 pending interaction 与流程状态;
|
||||
// 3. ConversationContext 提供历史对话;
|
||||
// 4. ConfirmAction 仅在 confirm 恢复场景下由前端传入 "accept" / "reject"。
|
||||
type ChatNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
ConfirmAction string
|
||||
ResumeInteractionID string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
CompactionStore agentmodel.CompactionStore // 上下文压缩持久化
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunChatNode 执行一轮聊天节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. 恢复判定:有 pending interaction 则处理恢复;
|
||||
// 2. 路由分流:无 pending 时,调 LLM 判断复杂度并路由;
|
||||
// 3. direct_reply:简单任务,直接输出回复 → END;
|
||||
// 4. execute:中等任务,推 Execute ReAct;
|
||||
// 5. deep_answer:复杂问答,原地开 thinking 深度回答 → END;
|
||||
// 6. plan:复杂规划,推 Plan 节点。
|
||||
func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareChatNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 有 pending interaction → 纯状态传递,处理恢复。
|
||||
if runtimeState.HasPendingInteraction() {
|
||||
return handleChatResume(input, runtimeState, emitter)
|
||||
}
|
||||
|
||||
// 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking)。
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if !runtimeState.HasPendingInteraction() && flowState.Phase == agentmodel.PhaseDone {
|
||||
terminalBefore := flowState.TerminalStatus()
|
||||
roundBefore := flowState.RoundUsed
|
||||
// 1. 只有"正常完成(completed)"才打 loop 收口标记:
|
||||
// 1.1 这样下一轮进入 execute 时,msg2 会只保留"当前活跃循环"窗口;
|
||||
// 1.2 异常收口(exhausted/aborted)不打标记,允许后续"继续"时沿用上一轮 loop 轨迹。
|
||||
if terminalBefore == agentmodel.FlowTerminalStatusCompleted {
|
||||
appendExecuteLoopClosedMarker(conversationContext)
|
||||
}
|
||||
flowState.ResetForNextRun()
|
||||
log.Printf(
|
||||
"[DEBUG] chat reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
||||
flowState.ConversationID,
|
||||
roundBefore,
|
||||
terminalBefore,
|
||||
)
|
||||
}
|
||||
nonce := uuid.NewString()
|
||||
messages := agentprompt.BuildChatRoutingMessages(conversationContext, input.UserInput, flowState, nonce)
|
||||
messages = compactUnifiedMessagesIfNeeded(ctx, messages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: chatStageName,
|
||||
StatusBlockID: chatStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(chatStageName, "routing", flowState, messages)
|
||||
|
||||
reader, err := input.Client.Stream(ctx, messages, llmservice.GenerateOptions{
|
||||
Temperature: 0.7,
|
||||
Thinking: llmservice.ThinkingModeDisabled,
|
||||
Metadata: map[string]any{
|
||||
"stage": chatStageName,
|
||||
"phase": "routing",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] chat routing stream failed chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
|
||||
parser := agentrouter.NewStreamRouteParser(nonce)
|
||||
return streamAndDispatch(ctx, reader, parser, input, emitter, flowState, conversationContext)
|
||||
}
|
||||
|
||||
// appendExecuteLoopClosedMarker 在 history 中写入"execute loop 已正常收口"标记。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责写一个轻量 marker,供 prompt 分层;
|
||||
// 2. 不负责历史裁剪,不负责消息摘要;
|
||||
// 3. 若末尾已经是同类 marker,则幂等跳过,避免重复写入。
|
||||
func appendExecuteLoopClosedMarker(conversationContext *agentmodel.ConversationContext) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
last := history[len(history)-1]
|
||||
if isExecuteLoopClosedMarker(last) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
Extra: map[string]any{
|
||||
chatHistoryKindKey: chatHistoryKindExecuteLoopClosed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func isExecuteLoopClosedMarker(msg *schema.Message) bool {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
kind, ok := msg.Extra[chatHistoryKindKey].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(kind) == chatHistoryKindExecuteLoopClosed
|
||||
}
|
||||
|
||||
// streamAndDispatch 是流式路由分发的核心循环。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 从 StreamReader 逐 chunk 读取,喂给 StreamRouteParser 增量解析控制码;
|
||||
// 2. 控制码解析完成后,根据 route 进入对应的流式处理分支;
|
||||
// 3. 控制码解析超时或流异常结束 → fallback 到 plan。
|
||||
func streamAndDispatch(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
parser *agentrouter.StreamRouteParser,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
) error {
|
||||
for {
|
||||
chunk, err := reader.Recv()
|
||||
if err == io.EOF {
|
||||
if !parser.RouteReady() {
|
||||
log.Printf("[WARN] chat stream ended before route resolved chat=%s", flowState.ConversationID)
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[WARN] chat stream recv error chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, routeReady, _ := parser.Feed(content)
|
||||
if !routeReady {
|
||||
continue
|
||||
}
|
||||
|
||||
// 控制码解析完成,进入路由分发。
|
||||
decision := parser.Decision()
|
||||
|
||||
// 二次粗排硬闸门:若上下文已存在 rough_build_done 且用户未明确要求"重新粗排",
|
||||
// 则强制关闭 needs_rough_build,避免"微调请求被误判成再次粗排"。
|
||||
if shouldDisableRoughBuildForRefine(conversationContext, input.UserInput, decision) {
|
||||
decision.NeedsRoughBuild = false
|
||||
decision.NeedsRefineAfterRoughBuild = false
|
||||
}
|
||||
// 首次粗排兜底:若用户未明确要求"只要初稿不优化",则粗排后默认进入主动微调。
|
||||
if shouldForceRefineAfterFirstRoughBuild(conversationContext, input.UserInput, decision) {
|
||||
decision.NeedsRefineAfterRoughBuild = true
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] chat routing chat=%s route=%s needs_rough_build=%v needs_refine_after_rough_build=%v allow_reorder=%v thinking=%v has_rough_build_done=%v task_class_count=%d raw=%s",
|
||||
flowState.ConversationID,
|
||||
decision.Route,
|
||||
decision.NeedsRoughBuild,
|
||||
decision.NeedsRefineAfterRoughBuild,
|
||||
decision.AllowReorder,
|
||||
decision.Thinking,
|
||||
hasRoughBuildDoneMarker(conversationContext),
|
||||
len(flowState.TaskClassIDs),
|
||||
decision.Raw,
|
||||
)
|
||||
|
||||
flowState.AllowReorder = resolveAllowReorder(input.UserInput, decision.AllowReorder)
|
||||
effectiveThinking := resolveEffectiveThinking(flowState.ThinkingMode, decision.Route, decision.Thinking)
|
||||
|
||||
switch decision.Route {
|
||||
case agentmodel.ChatRouteDirectReply:
|
||||
return handleDirectReplyStream(ctx, reader, input, emitter, conversationContext, flowState, effectiveThinking, visible)
|
||||
|
||||
case agentmodel.ChatRouteExecute:
|
||||
return handleRouteExecuteStream(reader, emitter, flowState, decision, input.UserInput, effectiveThinking, visible)
|
||||
|
||||
case agentmodel.ChatRouteDeepAnswer:
|
||||
return handleDeepAnswerStream(ctx, reader, input, emitter, conversationContext, flowState, effectiveThinking)
|
||||
|
||||
case agentmodel.ChatRoutePlan:
|
||||
return handleRoutePlanStream(reader, emitter, flowState, effectiveThinking, visible)
|
||||
|
||||
case agentmodel.ChatRouteQuickTask:
|
||||
// 关闭路由流,后续由 QuickTask 节点自行处理。
|
||||
_ = reader.Close()
|
||||
flowState.Phase = agentmodel.PhaseQuickTask
|
||||
return nil
|
||||
|
||||
default:
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveEffectiveThinking 根据前端 ThinkingMode 和路由决策合并出最终 thinking 状态。
|
||||
//
|
||||
// 规则:
|
||||
// 1. "true":前端强制开启,所有路由统一开;
|
||||
// 2. "false":前端强制关闭,所有路由统一关;
|
||||
// 3. "auto"/"":按路由语义兜底;
|
||||
// 3.1 deep_answer 的语义本身就是"复杂问答 + 原地深度思考",因此默认开启;
|
||||
// 3.2 execute 继续沿用路由模型给出的 decisionThinking;
|
||||
// 3.3 其余路由默认关闭,避免把轻量闲聊误升成高成本推理。
|
||||
func resolveEffectiveThinking(mode string, route agentmodel.ChatRoute, decisionThinking bool) bool {
|
||||
switch strings.TrimSpace(strings.ToLower(mode)) {
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
default:
|
||||
if route == agentmodel.ChatRouteDeepAnswer {
|
||||
return true
|
||||
}
|
||||
return decisionThinking
|
||||
}
|
||||
}
|
||||
|
||||
// handleDirectReplyStream 处理闲聊回复。
|
||||
//
|
||||
// 两种模式:
|
||||
// 1. thinking=false:同一流续传,逐 chunk 推送;
|
||||
// 2. thinking=true:关闭路由流,发起第二次 thinking 流式调用。
|
||||
func handleDirectReplyStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
effectiveThinking bool,
|
||||
firstVisible string,
|
||||
) error {
|
||||
if effectiveThinking {
|
||||
return handleThinkingReplyStream(ctx, reader, input, emitter, conversationContext, flowState)
|
||||
}
|
||||
return handleDirectReplyContinueStream(ctx, reader, input, emitter, conversationContext, flowState, firstVisible)
|
||||
}
|
||||
|
||||
// handleThinkingReplyStream 处理需要思考的回复:关闭路由流 → 第二次 thinking 流式调用。
|
||||
func handleThinkingReplyStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
) error {
|
||||
_ = reader.Close()
|
||||
|
||||
deepMessages := agentprompt.BuildDeepAnswerMessages(flowState, conversationContext, input.UserInput)
|
||||
deepMessages = compactUnifiedMessagesIfNeeded(ctx, deepMessages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: chatStageName,
|
||||
StatusBlockID: chatStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(chatStageName, "direct_reply_thinking", flowState, deepMessages)
|
||||
deepReader, err := input.Client.Stream(ctx, deepMessages, llmservice.GenerateOptions{
|
||||
Temperature: 0.5,
|
||||
MaxTokens: 2000,
|
||||
Thinking: llmservice.ThinkingModeEnabled,
|
||||
Metadata: map[string]any{
|
||||
"stage": chatStageName,
|
||||
"phase": "direct_reply_thinking",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] thinking reply stream failed chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
deepText, err := emitter.EmitStreamAssistantText(ctx, deepReader, chatSpeakBlockID, chatStageName)
|
||||
_ = deepReader.Close()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] thinking reply emit error chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
deepText = strings.TrimSpace(deepText)
|
||||
if deepText != "" {
|
||||
conversationContext.AppendHistory(schema.AssistantMessage(deepText, nil))
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, schema.AssistantMessage(deepText, nil))
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDirectReplyContinueStream 处理无思考的闲聊:同一流续传。
|
||||
func handleDirectReplyContinueStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
firstVisible string,
|
||||
) error {
|
||||
var fullText strings.Builder
|
||||
fullText.WriteString(firstVisible)
|
||||
|
||||
// 推送控制码之后的第一段内容。
|
||||
if strings.TrimSpace(firstVisible) != "" {
|
||||
if err := emitter.EmitAssistantText(chatSpeakBlockID, chatStageName, firstVisible, true); err != nil {
|
||||
return fmt.Errorf("闲聊回复推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
firstChunk := firstVisible == ""
|
||||
// 继续读同一个流,逐 chunk 推送。
|
||||
for {
|
||||
chunk, err := reader.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[WARN] direct_reply stream error chat=%s err=%v", flowState.ConversationID, err)
|
||||
break
|
||||
}
|
||||
if chunk == nil || chunk.Content == "" {
|
||||
continue
|
||||
}
|
||||
if err := emitter.EmitAssistantText(chatSpeakBlockID, chatStageName, chunk.Content, firstChunk); err != nil {
|
||||
return fmt.Errorf("闲聊回复推送失败: %w", err)
|
||||
}
|
||||
fullText.WriteString(chunk.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
|
||||
text := fullText.String()
|
||||
if strings.TrimSpace(text) != "" {
|
||||
msg := schema.AssistantMessage(text, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRouteExecuteStream 处理工具调用路由:推送状态确认 → 设 PhaseExecuting。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 关闭路由流(后续内容不需要);
|
||||
// 2. 推送轻量状态通知;
|
||||
// 3. 设置流程状态,进入 Execute 或 RoughBuild。
|
||||
func handleRouteExecuteStream(
|
||||
reader llmservice.StreamReader,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
userInput string,
|
||||
effectiveThinking bool,
|
||||
speak string,
|
||||
) error {
|
||||
// 关闭路由流。
|
||||
_ = reader.Close()
|
||||
|
||||
if strings.TrimSpace(speak) == "" {
|
||||
speak = "好的,我来处理。"
|
||||
}
|
||||
|
||||
// 推送轻量状态通知。
|
||||
_ = emitter.EmitStatus(chatStatusBlockID, chatStageName, "accepted", speak, false)
|
||||
|
||||
// 清空旧 PlanSteps 并设 PhaseExecuting。
|
||||
flowState.StartDirectExecute()
|
||||
|
||||
// 粗排开关逻辑。
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if decision.NeedsRoughBuild && len(flowState.TaskClassIDs) > 0 {
|
||||
flowState.NeedsRoughBuild = true
|
||||
flowState.NeedsRefineAfterRoughBuild = decision.NeedsRefineAfterRoughBuild
|
||||
}
|
||||
|
||||
flowState.ExecuteThinking = effectiveThinking
|
||||
flowState.OptimizationMode = resolveOptimizationMode(userInput, decision, flowState)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAllowReorder 统一计算"本轮是否允许打乱顺序"。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 后端先做显式语义判定:用户明确允许/明确禁止时,直接以后端判定为准;
|
||||
// 2. 若后端未识别到显式语义,再回退到路由模型的 allow_reorder 字段;
|
||||
// 3. 默认返回 false,确保"保持顺序"是系统默认行为。
|
||||
func resolveAllowReorder(userInput string, modelAllowReorder bool) bool {
|
||||
switch detectReorderPreference(userInput) {
|
||||
case reorderAllow:
|
||||
return true
|
||||
case reorderDisallow:
|
||||
return false
|
||||
default:
|
||||
return modelAllowReorder
|
||||
}
|
||||
}
|
||||
|
||||
// detectReorderPreference 识别用户是否"明确授权打乱顺序"。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责关键词级别的显式意图识别,不做复杂语义推理;
|
||||
// 2. 若同时命中"允许"与"禁止",优先按"禁止"处理,避免误放开顺序约束;
|
||||
// 3. 未命中显式表达时返回 unknown,交给上层兜底策略。
|
||||
func detectReorderPreference(userInput string) reorderPreference {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
disallowPhrases := []string{
|
||||
"不要打乱顺序",
|
||||
"不允许打乱顺序",
|
||||
"保持顺序",
|
||||
"顺序不变",
|
||||
"按原顺序",
|
||||
"不要乱序",
|
||||
"别打乱",
|
||||
}
|
||||
if containsAnyPhrase(text, disallowPhrases) {
|
||||
return reorderDisallow
|
||||
}
|
||||
|
||||
allowPhrases := []string{
|
||||
"可以打乱顺序",
|
||||
"允许打乱顺序",
|
||||
"顺序不重要",
|
||||
"顺序无所谓",
|
||||
"顺序不限",
|
||||
"允许乱序",
|
||||
"可以乱序",
|
||||
"允许重排顺序",
|
||||
"reorder is fine",
|
||||
"any order",
|
||||
}
|
||||
if containsAnyPhrase(text, allowPhrases) {
|
||||
return reorderAllow
|
||||
}
|
||||
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
// resolveOptimizationMode 统一确定当前 execute 的优化模式。
|
||||
func resolveOptimizationMode(
|
||||
userInput string,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) string {
|
||||
if decision != nil && decision.NeedsRoughBuild && flowState != nil && len(flowState.TaskClassIDs) > 0 {
|
||||
return "first_full"
|
||||
}
|
||||
if isExplicitGlobalReoptRequest(userInput) {
|
||||
return "global_reopt"
|
||||
}
|
||||
return "local_adjust"
|
||||
}
|
||||
|
||||
// isExplicitGlobalReoptRequest 识别用户是否明确要求全局重优化。
|
||||
func isExplicitGlobalReoptRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"全局优化",
|
||||
"整体优化",
|
||||
"全局重排",
|
||||
"整体重排",
|
||||
"重新优化全部",
|
||||
"重新优化整体",
|
||||
"全面优化",
|
||||
"整体体检",
|
||||
"全局体检",
|
||||
"重新体检",
|
||||
"global optimize",
|
||||
"global reopt",
|
||||
"overall optimize",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
func containsAnyPhrase(text string, phrases []string) bool {
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(text, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldDisableRoughBuildForRefine 判断是否应在 chat 路由阶段关闭"再次粗排"。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 当前决策未请求粗排时,直接不干预;
|
||||
// 2. 上下文不存在 rough_build_done 时,不干预(首次粗排仍可走);
|
||||
// 3. 若用户未明确要求"重新粗排/从头重排",则关闭粗排开关,避免误触发。
|
||||
func shouldDisableRoughBuildForRefine(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
userInput string,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
) bool {
|
||||
if decision == nil || !decision.NeedsRoughBuild {
|
||||
return false
|
||||
}
|
||||
if !hasRoughBuildDoneMarker(conversationContext) {
|
||||
return false
|
||||
}
|
||||
return !isExplicitRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
// shouldForceRefineAfterFirstRoughBuild 判断是否应在"首次粗排"场景下强制开启 refine。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 仅在当前决策仍然请求粗排时生效;
|
||||
// 2. 仅在首次粗排(上下文不存在 rough_build_done)时生效;
|
||||
// 3. 若用户明确表达"只要初稿/先不优化",则不强制开启;
|
||||
// 4. 其余首次粗排场景一律开启,确保符合 PRD 的默认主动优化策略。
|
||||
func shouldForceRefineAfterFirstRoughBuild(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
userInput string,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
) bool {
|
||||
if decision == nil || !decision.NeedsRoughBuild {
|
||||
return false
|
||||
}
|
||||
if hasRoughBuildDoneMarker(conversationContext) {
|
||||
return false
|
||||
}
|
||||
return !isExplicitNoRefineAfterRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
func hasRoughBuildDoneMarker(conversationContext *agentmodel.ConversationContext) bool {
|
||||
if conversationContext == nil {
|
||||
return false
|
||||
}
|
||||
for _, block := range conversationContext.PinnedBlocksSnapshot() {
|
||||
if strings.TrimSpace(block.Key) == "rough_build_done" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isExplicitRoughBuildRequest 识别用户是否明确要求"重新粗排/从头重排"。
|
||||
func isExplicitRoughBuildRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"重新粗排",
|
||||
"重做粗排",
|
||||
"从头排",
|
||||
"从头重排",
|
||||
"重新排一遍",
|
||||
"重新排课",
|
||||
"重排全部",
|
||||
"全部重排",
|
||||
"重置排程",
|
||||
"重置后重排",
|
||||
"重新生成初稿",
|
||||
"rebuild",
|
||||
"from scratch",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// isExplicitNoRefineAfterRoughBuildRequest 识别用户是否明确要求"粗排后先不要自动微调"。
|
||||
func isExplicitNoRefineAfterRoughBuildRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"只要初稿",
|
||||
"先给初稿",
|
||||
"先排进去就行",
|
||||
"先排进去",
|
||||
"先不优化",
|
||||
"先别优化",
|
||||
"先不微调",
|
||||
"先别微调",
|
||||
"排完就收口",
|
||||
"粗排就行",
|
||||
"草稿就行",
|
||||
"draft only",
|
||||
"no refine",
|
||||
"no optimization",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// handleDeepAnswerStream 处理复杂问答:关闭路由流 → 第二次流式调用。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 关闭第一个路由流;
|
||||
// 2. 发起第二次流式 LLM 调用(thinking 由 effectiveThinking 控制);
|
||||
// 3. 真流式推送 reasoning + 正文;
|
||||
// 4. 完整回复写入 history。
|
||||
func handleDeepAnswerStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
effectiveThinking bool,
|
||||
) error {
|
||||
// 1. 关闭第一个路由流。
|
||||
_ = reader.Close()
|
||||
|
||||
// 2. 第二次流式调用。
|
||||
thinkingOpt := llmservice.ThinkingModeDisabled
|
||||
if effectiveThinking {
|
||||
thinkingOpt = llmservice.ThinkingModeEnabled
|
||||
}
|
||||
deepMessages := agentprompt.BuildDeepAnswerMessages(flowState, conversationContext, input.UserInput)
|
||||
deepMessages = compactUnifiedMessagesIfNeeded(ctx, deepMessages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: chatStageName,
|
||||
StatusBlockID: chatStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(chatStageName, "deep_answer", flowState, deepMessages)
|
||||
deepReader, err := input.Client.Stream(ctx, deepMessages, llmservice.GenerateOptions{
|
||||
Temperature: 0.5,
|
||||
MaxTokens: 2000,
|
||||
Thinking: thinkingOpt,
|
||||
Metadata: map[string]any{
|
||||
"stage": chatStageName,
|
||||
"phase": "deep_answer",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// 深度回答失败 → 降级返回。
|
||||
log.Printf("[WARN] deep answer stream failed chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 真流式推送 reasoning + 正文。
|
||||
deepText, err := emitter.EmitStreamAssistantText(ctx, deepReader, chatSpeakBlockID, chatStageName)
|
||||
_ = deepReader.Close()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] deep answer stream emit error chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
deepText = strings.TrimSpace(deepText)
|
||||
if deepText == "" {
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 完整回复写入 history。
|
||||
msg := schema.AssistantMessage(deepText, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRoutePlanStream 处理规划路由:推送状态确认 → 设 PhasePlanning。
|
||||
func handleRoutePlanStream(
|
||||
reader llmservice.StreamReader,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
effectiveThinking bool,
|
||||
speak string,
|
||||
) error {
|
||||
// 关闭路由流。
|
||||
_ = reader.Close()
|
||||
|
||||
if strings.TrimSpace(speak) == "" {
|
||||
speak = "好的,让我来规划一下。"
|
||||
}
|
||||
|
||||
_ = emitter.EmitStatus(chatStatusBlockID, chatStageName, "planning", speak, false)
|
||||
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── 恢复处理(保持原有逻辑不变)───
|
||||
|
||||
// handleChatResume 处理 pending interaction 恢复。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做状态传递:吞掉用户输入、写回历史、恢复 phase;
|
||||
// 2. 不生成 speak,真正的回复由下游 Plan / Execute 节点产出;
|
||||
// 3. 只推送轻量 status 通知前端"已收到回复,正在继续"。
|
||||
func handleChatResume(
|
||||
input ChatNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
pending := runtimeState.PendingInteraction
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
if isMismatchedResumeInteraction(input.ResumeInteractionID, pending) {
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"stale_resume", "当前确认已过期,请刷新后重试。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 用户输入在 service 层进入 graph 前已经统一追加到 ConversationContext。
|
||||
// 这里不再二次写入,避免 pending 恢复路径把同一轮 user message 追加两次。
|
||||
|
||||
switch pending.Type {
|
||||
case agentmodel.PendingInteractionTypeAskUser:
|
||||
// 用户回答了问题 → 恢复 phase,交给下游节点继续。
|
||||
runtimeState.ResumeFromPending()
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"resumed", "收到回复,继续处理。", false,
|
||||
)
|
||||
return nil
|
||||
|
||||
case agentmodel.PendingInteractionTypeConfirm:
|
||||
return handleConfirmResume(input, runtimeState, flowState, pending, emitter)
|
||||
|
||||
default:
|
||||
// connection_lost 等其他类型 → 直接恢复。
|
||||
runtimeState.ResumeFromPending()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfirmResume 处理 confirm 类型恢复。
|
||||
//
|
||||
// 分支逻辑:
|
||||
// 1. accept → 恢复后 phase 设为 executing,下游 Execute 节点接管;
|
||||
// 2. reject + 有 PendingTool(工具确认)→ 回到 executing 让 Execute 节点换策略;
|
||||
// 3. reject + 无 PendingTool(计划确认)→ 清空计划,回到 planning 重新规划。
|
||||
func handleConfirmResume(
|
||||
input ChatNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
pending *agentmodel.PendingInteraction,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
if isMismatchedResumeInteraction(input.ResumeInteractionID, pending) {
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"stale_resume", "当前确认已过期,请刷新后重试。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(input.ConfirmAction))
|
||||
|
||||
switch action {
|
||||
case "accept", "approve":
|
||||
// 恢复前保存待执行工具,Execute 节点需要它。
|
||||
pendingTool := pending.PendingTool
|
||||
runtimeState.ResumeFromPending()
|
||||
// 将待执行工具放回临时邮箱,供 Execute 节点执行。
|
||||
if pendingTool != nil {
|
||||
copied := *pendingTool
|
||||
runtimeState.PendingConfirmTool = &copied
|
||||
}
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"confirmed", "已确认,开始执行。", false,
|
||||
)
|
||||
|
||||
case "reject", "cancel":
|
||||
runtimeState.ResumeFromPending()
|
||||
if pending.PendingTool != nil {
|
||||
// 工具确认被拒 → 回到 executing 换策略。
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
} else {
|
||||
// 计划确认被拒 → 清空计划,回到 planning。
|
||||
flowState.RejectPlan()
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"rejected", "已取消,准备重新规划。", false,
|
||||
)
|
||||
|
||||
default:
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"invalid_confirm_action", "未识别确认动作,请重试。", false,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isMismatchedResumeInteraction(resumeInteractionID string, pending *agentmodel.PendingInteraction) bool {
|
||||
if pending == nil {
|
||||
return false
|
||||
}
|
||||
resumeID := strings.TrimSpace(resumeInteractionID)
|
||||
pendingID := strings.TrimSpace(pending.InteractionID)
|
||||
if resumeID == "" || pendingID == "" {
|
||||
return false
|
||||
}
|
||||
return resumeID != pendingID
|
||||
}
|
||||
|
||||
// prepareChatNodeInput 校验并准备聊天节点的运行态依赖。
|
||||
func prepareChatNodeInput(input ChatNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("chat node: runtime state 不能为空")
|
||||
}
|
||||
if input.Client == nil {
|
||||
return nil, nil, nil, fmt.Errorf("chat node: chat client 未注入")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
208
backend/services/agent/node/confirm.go
Normal file
208
backend/services/agent/node/confirm.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
)
|
||||
|
||||
const (
|
||||
confirmStageName = "confirm"
|
||||
confirmStatusBlockID = "confirm.status"
|
||||
)
|
||||
|
||||
// ConfirmNodeInput 描述确认节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 不需要 LLM Client — 确认内容由已有状态机械格式化,不调模型;
|
||||
// 2. RuntimeState 提供计划步骤和待确认工具快照;
|
||||
// 3. ChunkEmitter 负责推送确认事件到前端。
|
||||
type ConfirmNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
}
|
||||
|
||||
// RunConfirmNode 执行一轮确认节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. 判断确认来源:有 PendingConfirmTool → 工具确认;有 PlanSteps → 计划确认;
|
||||
// 2. 机械格式化确认内容(不需要 LLM 调用);
|
||||
// 3. 推送确认事件 EmitConfirmRequest → 前端渲染确认卡片;
|
||||
// 4. 调用 OpenConfirmInteraction 固化中断快照,Phase 自动变为 interrupted。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 不等待用户响应 — 等待是 interruptNode 的职责;
|
||||
// 2. 不执行任何工具 — 只固化"意图",执行留给恢复后的 Execute;
|
||||
// 3. Confirm 是图里唯一负责"生成确认事件 + 固化快照"的地方,上游节点只设 Phase。
|
||||
func RunConfirmNode(ctx context.Context, input ConfirmNodeInput) error {
|
||||
runtimeState, _, emitter, err := prepareConfirmNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
// 优先处理工具确认(Execute 发起的写操作确认)。
|
||||
if runtimeState.PendingConfirmTool != nil {
|
||||
return handleToolConfirm(ctx, runtimeState, flowState, emitter)
|
||||
}
|
||||
|
||||
// 其次处理计划确认(Plan 完成后的整体验收)。
|
||||
if flowState.HasPlan() {
|
||||
return handlePlanConfirm(ctx, runtimeState, flowState, emitter)
|
||||
}
|
||||
|
||||
// 既没有工具也没有计划 → 异常状态,不应到达此处。
|
||||
return fmt.Errorf("confirm node: 没有可确认的内容(无计划、无待确认工具)")
|
||||
}
|
||||
|
||||
// handlePlanConfirm 处理计划确认。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 从 flowState.PlanSteps 格式化可读摘要;
|
||||
// 2. 推送确认事件到前端;
|
||||
// 3. 调用 OpenConfirmInteraction 固化快照(无 PendingTool)。
|
||||
func handlePlanConfirm(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
summary := buildPlanSummary(flowState.PlanSteps)
|
||||
interactionID := generateConfirmInteractionID(flowState)
|
||||
|
||||
if err := emitter.EmitConfirmRequest(
|
||||
ctx, confirmStatusBlockID, confirmStageName,
|
||||
interactionID,
|
||||
"计划确认",
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("计划确认事件推送失败: %w", err)
|
||||
}
|
||||
|
||||
runtimeState.OpenConfirmInteraction(
|
||||
interactionID,
|
||||
summary,
|
||||
"plan",
|
||||
nil,
|
||||
)
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
confirmStatusBlockID, confirmStageName,
|
||||
"plan_confirm", "计划已生成,等待用户确认。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleToolConfirm 处理工具确认。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 从 PendingConfirmTool 构建确认摘要;
|
||||
// 2. 推送确认事件到前端;
|
||||
// 3. 调用 OpenConfirmInteraction 固化快照(含 PendingTool);
|
||||
// 4. 清空 PendingConfirmTool 临时邮箱。
|
||||
func handleToolConfirm(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
pendingTool := runtimeState.PendingConfirmTool
|
||||
summary := buildToolConfirmSummary(pendingTool)
|
||||
interactionID := generateConfirmInteractionID(flowState)
|
||||
|
||||
if err := emitter.EmitConfirmRequest(
|
||||
ctx, confirmStatusBlockID, confirmStageName,
|
||||
interactionID,
|
||||
"操作确认",
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("工具确认事件推送失败: %w", err)
|
||||
}
|
||||
|
||||
runtimeState.OpenConfirmInteraction(
|
||||
interactionID,
|
||||
summary,
|
||||
"execute",
|
||||
pendingTool,
|
||||
)
|
||||
|
||||
// 确认快照已固化到 PendingInteraction,清空临时邮箱。
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
confirmStatusBlockID, confirmStageName,
|
||||
"tool_confirm", "操作等待确认。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPlanSummary 把 PlanSteps 格式化成人类可读的确认摘要。
|
||||
func buildPlanSummary(steps []agentmodel.PlanStep) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("共 %d 步:\n", len(steps)))
|
||||
for i, step := range steps {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s", i+1, step.Content))
|
||||
if step.DoneWhen != "" {
|
||||
sb.WriteString(fmt.Sprintf("(完成条件:%s)", step.DoneWhen))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// buildToolConfirmSummary 从工具快照构建确认摘要。
|
||||
func buildToolConfirmSummary(tool *agentmodel.PendingToolCallSnapshot) string {
|
||||
if tool == nil {
|
||||
return "待确认操作"
|
||||
}
|
||||
if tool.Summary != "" {
|
||||
return tool.Summary
|
||||
}
|
||||
detail := fmt.Sprintf("即将执行工具:%s", tool.ToolName)
|
||||
if tool.ArgsJSON != "" {
|
||||
var args map[string]any
|
||||
if json.Unmarshal([]byte(tool.ArgsJSON), &args) == nil && len(args) > 0 {
|
||||
detail += fmt.Sprintf(",参数:%s", tool.ArgsJSON)
|
||||
}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
// generateConfirmInteractionID 生成确认交互的唯一标识。
|
||||
func generateConfirmInteractionID(flowState *agentmodel.CommonState) string {
|
||||
prefix := flowState.TraceID
|
||||
if prefix == "" {
|
||||
prefix = "confirm"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// prepareConfirmNodeInput 校验并准备确认节点的运行态依赖。
|
||||
func prepareConfirmNodeInput(input ConfirmNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("confirm node: runtime state 不能为空")
|
||||
}
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
136
backend/services/agent/node/correction.go
Normal file
136
backend/services/agent/node/correction.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
correctionHistoryKindKey = "newagent_history_kind"
|
||||
correctionHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
|
||||
// AppendLLMCorrection 追加 LLM 修正提示到对话历史。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 当 LLM 输出不符合预期(如不支持的 action、格式错误等),不应直接报错终止;
|
||||
// 2. 应该给 LLM 一个自我修正的机会,把错误反馈写回历史,让它重新生成;
|
||||
// 3. 该函数封装了"追加 assistant 消息 + 追加纠正提示"的通用流程。
|
||||
//
|
||||
// 参数说明:
|
||||
// - conversationContext: 对话上下文,用于追加历史消息;
|
||||
// - llmOutput: LLM 的原始输出内容,会作为 assistant 消息追加;
|
||||
// - validOptionsDesc: 合法选项的描述,用于构造纠正提示。
|
||||
//
|
||||
// 使用示例:
|
||||
//
|
||||
// AppendLLMCorrection(conversationContext, decision.Speak, "合法的 action 包括:continue、ask_user、next_plan、done")
|
||||
//
|
||||
// 返回值:
|
||||
// - 返回 nil 表示修正流程完成,调用方应继续 Graph 循环;
|
||||
// - 该函数不会返回 error,因为追加历史失败不影响主流程。
|
||||
func AppendLLMCorrection(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。
|
||||
// 2. 空输出不回灌,避免把占位文本写进历史造成噪音。
|
||||
// 3. 与最近一条 assistant 完全相同则跳过,避免重复回灌放大复读。
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
// 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。
|
||||
// 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。
|
||||
correctionContent := fmt.Sprintf(
|
||||
"你的输出不符合预期。%s 请重新分析当前状态,输出正确的内容。",
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AppendLLMCorrectionWithHint 追加 LLM 修正提示(带自定义错误描述)。
|
||||
//
|
||||
// 相比 AppendLLMCorrection,该函数允许调用方提供更详细的错误描述,
|
||||
// 适用于需要明确告知 LLM 具体哪里出错的场景。
|
||||
//
|
||||
// 参数说明:
|
||||
// - conversationContext: 对话上下文;
|
||||
// - llmOutput: LLM 的原始输出内容;
|
||||
// - errorDesc: 具体的错误描述,如 "action \"invalid\" 不是合法的执行动作";
|
||||
// - validOptionsDesc: 合法选项的描述。
|
||||
func AppendLLMCorrectionWithHint(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
errorDesc string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"%s %s 请重新分析当前状态,输出正确的内容。",
|
||||
errorDesc,
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。
|
||||
//
|
||||
// 1. 空文本直接跳过,避免写入“占位噪音”;
|
||||
// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌;
|
||||
// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。
|
||||
func appendCorrectionAssistantIfNeeded(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
assistantContent string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
assistantContent = strings.TrimSpace(assistantContent)
|
||||
if assistantContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(msg.Content) == assistantContent {
|
||||
return
|
||||
}
|
||||
// 只看最近一条 assistant,避免误去重很久以前的正常重复表达。
|
||||
break
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
}
|
||||
276
backend/services/agent/node/deliver.go
Normal file
276
backend/services/agent/node/deliver.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
deliverStageName = "deliver"
|
||||
deliverStatusBlockID = "deliver.status"
|
||||
deliverSpeakBlockID = "deliver.speak"
|
||||
)
|
||||
|
||||
// DeliverNodeInput 描述交付节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责生成交付总结并推送给用户,不负责后续流程推进;
|
||||
// 2. RuntimeState 提供计划步骤和执行状态;
|
||||
// 3. ConversationContext 提供执行阶段的对话历史;
|
||||
// 4. 交付完成后标记流程结束。
|
||||
type DeliverNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
ThinkingEnabled bool // 是否开启 thinking,由 config.yaml 的 agent.thinking.deliver 注入
|
||||
CompactionStore agentmodel.CompactionStore // 上下文压缩持久化
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunDeliverNode 执行一轮交付节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. 调 LLM 基于原始计划 + 执行历史生成交付总结;
|
||||
// 2. 伪流式推送总结给用户;
|
||||
// 3. 写入对话历史,保证上下文连续;
|
||||
// 4. 标记流程结束。
|
||||
//
|
||||
// 降级策略:
|
||||
// 1. LLM 调用失败时,回退到机械格式化总结,不中断流程;
|
||||
// 2. 机械总结包含计划步骤列表和完成进度。
|
||||
func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareDeliverNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
// 1. 推送交付阶段状态,让前端知道正在生成总结。
|
||||
if err := emitter.EmitStatus(
|
||||
deliverStatusBlockID,
|
||||
deliverStageName,
|
||||
"summarizing",
|
||||
"正在生成交付总结。",
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("交付阶段状态推送失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 在线流式消息会把 execute / deliver 的正文追加到同一条 assistant 气泡。
|
||||
// 2.1 deliver 的 LLM 真流式路径不会经过 normalizeSpeak,因此第一段总结可能贴住上一段 execute 正文。
|
||||
// 2.2 这里先发一个仅用于 SSE 展示的段落分隔;不写入 history,避免历史回放和持久化消息额外多空行。
|
||||
// 2.3 若本轮 deliver 前没有任何正文,前端 Markdown 渲染会 trim 掉开头空行,不影响首段展示。
|
||||
if err := emitter.EmitAssistantText(deliverSpeakBlockID, deliverStageName, "\n\n", false); err != nil {
|
||||
return fmt.Errorf("交付总结段落分隔推送失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 调 LLM 生成交付总结。
|
||||
summary, streamed := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter)
|
||||
|
||||
// 3.1 排程完毕卡片信号:
|
||||
// 1. 仅在流程正常完成且确实产生过日程变更(粗排或写工具)时推送;
|
||||
// 2. 前端收到 kind=schedule_completed 后,自行用对话 ID 调用现有接口拉取排程数据渲染卡片;
|
||||
// 3. 不携带 Redis key 或排程数据,保持信号职责单一。
|
||||
if flowState.IsCompleted() && flowState.HasScheduleChanges {
|
||||
_ = emitter.EmitScheduleCompleted(deliverStatusBlockID, deliverStageName)
|
||||
}
|
||||
|
||||
// 4. 推送总结。LLM 路径已在 generateDeliverSummary 内部真流式推送,
|
||||
// 仅机械/降级路径需要在此伪流式补推。
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
if !streamed {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
deliverSpeakBlockID,
|
||||
deliverStageName,
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("交付总结推送失败: %w", err)
|
||||
}
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
} else {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 推送最终完成状态。
|
||||
_ = emitter.EmitStatus(
|
||||
deliverStatusBlockID,
|
||||
deliverStageName,
|
||||
"done",
|
||||
"本轮流程已结束。",
|
||||
true,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateDeliverSummary 尝试调用 LLM 生成交付总结,失败时降级到机械格式化。
|
||||
//
|
||||
// 返回值:
|
||||
// - summary:完整总结文本(用于历史写入);
|
||||
// - streamed:true 表示文本已通过 EmitStreamAssistantText 真流式推送到前端,调用方无需再伪流式。
|
||||
func generateDeliverSummary(
|
||||
ctx context.Context,
|
||||
client *llmservice.Client,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
thinkingEnabled bool,
|
||||
compactionStore agentmodel.CompactionStore,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) (string, bool) {
|
||||
if flowState != nil {
|
||||
switch {
|
||||
case flowState.IsAborted():
|
||||
return normalizeSpeak(buildAbortSummary(flowState)), false
|
||||
case flowState.IsExhaustedTerminal():
|
||||
return normalizeSpeak(buildExhaustedSummary(flowState)), false
|
||||
}
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
messages := agentprompt.BuildDeliverMessages(flowState, conversationContext)
|
||||
messages = compactUnifiedMessagesIfNeeded(ctx, messages, UnifiedCompactInput{
|
||||
Client: client,
|
||||
CompactionStore: compactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: deliverStageName,
|
||||
StatusBlockID: deliverStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(deliverStageName, "summarizing", flowState, messages)
|
||||
|
||||
reader, err := client.Stream(
|
||||
ctx,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 0.5,
|
||||
MaxTokens: 800,
|
||||
Thinking: resolveThinkingMode(thinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": deliverStageName,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] deliver Stream 调用失败,降级到机械总结: %v", err)
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
fullText, streamErr := emitter.EmitStreamAssistantText(ctx, reader, deliverSpeakBlockID, deliverStageName)
|
||||
if streamErr != nil || strings.TrimSpace(fullText) == "" {
|
||||
log.Printf("[WARN] deliver 流式推送失败或结果为空,降级到机械总结: streamErr=%v textLen=%d", streamErr, len(fullText))
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
return normalizeSpeak(fullText), true
|
||||
}
|
||||
|
||||
// buildAbortSummary 生成“流程已终止”的统一交付文案。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 第二轮开始,abort 的用户可见文案由终止方提前写入 CommonState;
|
||||
// 2. deliver 不再重新猜测或改写业务异常,只做最终收口;
|
||||
// 3. 若历史快照缺失 user_message,则回退到一份通用说明,避免前端收到空白结果。
|
||||
func buildAbortSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil || state.TerminalOutcome == nil {
|
||||
return "本轮流程已终止。"
|
||||
}
|
||||
if msg := strings.TrimSpace(state.TerminalOutcome.UserMessage); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return "本轮流程已终止,请根据当前提示检查后再继续。"
|
||||
}
|
||||
|
||||
// buildExhaustedSummary 生成“轮次耗尽”的统一收口文案。
|
||||
func buildExhaustedSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return "本轮执行已达到安全轮次上限,当前先停止继续操作。"
|
||||
}
|
||||
|
||||
prefix := "本轮执行已达到安全轮次上限,当前先停止继续操作。"
|
||||
if state.TerminalOutcome != nil && strings.TrimSpace(state.TerminalOutcome.UserMessage) != "" {
|
||||
prefix = strings.TrimSpace(state.TerminalOutcome.UserMessage)
|
||||
}
|
||||
if !state.HasPlan() {
|
||||
return prefix
|
||||
}
|
||||
return prefix + "\n\n" + strings.TrimSpace(buildMechanicalSummary(state))
|
||||
}
|
||||
|
||||
// buildMechanicalSummary 在 LLM 不可用时,机械拼接一份最小可用总结。
|
||||
func buildMechanicalSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return "任务流程已结束。"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
current, total := state.PlanProgress()
|
||||
|
||||
if !state.HasPlan() {
|
||||
return "任务流程已结束。"
|
||||
}
|
||||
|
||||
if state.IsExhaustedTerminal() {
|
||||
sb.WriteString(fmt.Sprintf("任务因执行轮次耗尽提前结束,已完成 %d/%d 步。\n", current, total))
|
||||
} else {
|
||||
sb.WriteString("所有计划步骤已执行完毕。\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n执行情况:\n")
|
||||
for i, step := range state.PlanSteps {
|
||||
marker := "[ ]"
|
||||
if i < current {
|
||||
marker = "[x]"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n", marker, strings.TrimSpace(step.Content)))
|
||||
}
|
||||
|
||||
if state.IsExhaustedTerminal() && current < total {
|
||||
sb.WriteString("\n如需继续完成剩余步骤,可以告诉我继续。")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// prepareDeliverNodeInput 校验并准备交付节点的运行态依赖。
|
||||
func prepareDeliverNodeInput(input DeliverNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("deliver node: runtime state 不能为空")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
14
backend/services/agent/node/execute.go
Normal file
14
backend/services/agent/node/execute.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
agentexecute "github.com/LoveLosita/smartflow/backend/services/agent/node/execute"
|
||||
)
|
||||
|
||||
type ExecuteNodeInput = agentexecute.ExecuteNodeInput
|
||||
type ExecuteRoundObservation = agentexecute.ExecuteRoundObservation
|
||||
|
||||
func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
return agentexecute.RunExecuteNode(ctx, input)
|
||||
}
|
||||
522
backend/services/agent/node/execute/action_router.go
Normal file
522
backend/services/agent/node/execute/action_router.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type executeDecisionStreamOutput struct {
|
||||
decision *agentmodel.ExecuteDecision
|
||||
rawText string
|
||||
parsedBeforeText string
|
||||
parsedAfterText string
|
||||
streamedSpeak string
|
||||
speakStreamed bool
|
||||
firstChunk bool
|
||||
}
|
||||
|
||||
func collectExecuteDecisionFromLLM(
|
||||
ctx context.Context,
|
||||
input ExecuteNodeInput,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
messages []*schema.Message,
|
||||
) (*executeDecisionStreamOutput, error) {
|
||||
reader, err := input.Client.Stream(
|
||||
ctx,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 1.0,
|
||||
MaxTokens: 131072,
|
||||
Thinking: agentshared.ResolveThinkingMode(input.ThinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": executeStageName,
|
||||
"step_index": flowState.CurrentStep,
|
||||
"round_used": flowState.RoundUsed,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行阶段 Stream 请求失败: %w", err)
|
||||
}
|
||||
|
||||
parser := agentrouter.NewStreamDecisionParser()
|
||||
output := &executeDecisionStreamOutput{firstChunk: true}
|
||||
var fullText strings.Builder
|
||||
reasoningDigestor, digestorErr := emitter.NewReasoningDigestor(ctx, executeSpeakBlockID, executeStageName)
|
||||
if digestorErr != nil {
|
||||
return nil, fmt.Errorf("执行 thinking 摘要器初始化失败: %w", digestorErr)
|
||||
}
|
||||
defer func() {
|
||||
if reasoningDigestor != nil {
|
||||
_ = reasoningDigestor.Close(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] execute stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
output.rawText = result.RawBuffer
|
||||
output.parsedBeforeText = result.BeforeText
|
||||
output.parsedAfterText = result.AfterText
|
||||
|
||||
if result.Fallback || result.ParseFailed {
|
||||
log.Printf(
|
||||
"[DEBUG] execute LLM 决策解析失败 chat=%s round=%d raw=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
output.rawText,
|
||||
)
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return nil, fmt.Errorf(
|
||||
"连续 %d 次解析决策 JSON 失败,终止执行。原始输出=%s",
|
||||
flowState.ConsecutiveCorrections,
|
||||
output.rawText,
|
||||
)
|
||||
}
|
||||
|
||||
errorDesc := "未识别到合法的 SMARTFLOW_DECISION 标签,无法继续解析。"
|
||||
optionHint := "请输出一个 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,然后再在标签外补充可见文本。"
|
||||
if strings.Contains(output.rawText, `"tool_call": [`) || strings.Contains(output.rawText, `"tool_call":[`) {
|
||||
errorDesc = "检测到 tool_call 字段被错误写成数组;每次只允许调用一个工具,不支持数组形式。"
|
||||
optionHint = "请把多次工具调用拆开,每次只保留一个 tool_call,然后再继续下一轮。"
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(conversationContext, output.rawText, errorDesc, optionHint)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decision, parseErr := llmservice.ParseJSONObject[agentmodel.ExecuteDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
log.Printf(
|
||||
"[DEBUG] execute LLM JSON 解析失败 chat=%s round=%d json=%s raw=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
result.DecisionJSON,
|
||||
output.rawText,
|
||||
)
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return nil, fmt.Errorf(
|
||||
"连续 %d 次解析决策 JSON 失败,终止执行。原始输出=%s",
|
||||
flowState.ConsecutiveCorrections,
|
||||
output.rawText,
|
||||
)
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
"决策标签内的 JSON 格式不合法。",
|
||||
"请确保 <SMARTFLOW_DECISION> 标签内是合法 JSON;当 action=next_plan/done 时,goal_check 必须是字符串(不要输出对象)。",
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
output.decision = decision
|
||||
|
||||
if visible != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
visible,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return nil, fmt.Errorf("执行回答推送失败: %w", emitErr)
|
||||
}
|
||||
output.speakStreamed = true
|
||||
fullText.WriteString(visible)
|
||||
output.firstChunk = false
|
||||
}
|
||||
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] execute speak stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(chunk2.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk2.ReasoningContent)
|
||||
}
|
||||
}
|
||||
if chunk2.Content != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
chunk2.Content,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return nil, fmt.Errorf("执行回答推送失败: %w", emitErr)
|
||||
}
|
||||
output.speakStreamed = true
|
||||
fullText.WriteString(chunk2.Content)
|
||||
output.firstChunk = false
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if output.decision == nil {
|
||||
if strings.TrimSpace(output.rawText) == "" {
|
||||
log.Printf(
|
||||
"[WARN] execute LLM 返回空文本 chat=%s round=%d consecutive=%d/%d",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
flowState.ConsecutiveCorrections+1,
|
||||
maxConsecutiveCorrections,
|
||||
)
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return nil, fmt.Errorf("连续 %d 次模型返回空文本,终止执行", flowState.ConsecutiveCorrections)
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
"模型没有返回任何内容。",
|
||||
"请至少返回一个 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION> 形式的执行决策。",
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("执行阶段模型输出中未提取到决策标签")
|
||||
}
|
||||
|
||||
output.streamedSpeak = fullText.String()
|
||||
output.decision.Speak = pickExecuteVisibleSpeak(
|
||||
output.streamedSpeak,
|
||||
output.parsedAfterText,
|
||||
output.parsedBeforeText,
|
||||
output.decision,
|
||||
)
|
||||
log.Printf(
|
||||
"[DEBUG] execute LLM 响应 chat=%s round=%d action=%s speak_len=%d raw_len=%d raw_preview=%.200s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
output.decision.Action,
|
||||
len(output.decision.Speak),
|
||||
len(output.rawText),
|
||||
output.rawText,
|
||||
)
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func handleExecuteDecision(
|
||||
ctx context.Context,
|
||||
input ExecuteNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
output *executeDecisionStreamOutput,
|
||||
) error {
|
||||
if output == nil || output.decision == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
decision := output.decision
|
||||
if decision.Action == agentmodel.ExecuteActionDone &&
|
||||
decision.ToolCall != nil &&
|
||||
strings.EqualFold(strings.TrimSpace(decision.ToolCall.Name), agenttools.ToolNameContextToolsRemove) {
|
||||
decision.ToolCall = nil
|
||||
}
|
||||
|
||||
if err := decision.Validate(); err != nil {
|
||||
flowState.ConsecutiveCorrections++
|
||||
log.Printf(
|
||||
"[WARN] execute 决策不合法 chat=%s round=%d consecutive=%d/%d err=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
flowState.ConsecutiveCorrections,
|
||||
maxConsecutiveCorrections,
|
||||
err.Error(),
|
||||
)
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf(
|
||||
"连续 %d 次决策不合法,终止执行。%s (原始输出: %s)",
|
||||
flowState.ConsecutiveCorrections,
|
||||
err.Error(),
|
||||
output.rawText,
|
||||
)
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf("执行校验:决策不合法:%s,已请求模型重试。", err.Error()),
|
||||
false,
|
||||
)
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("本次执行决策不合法:%s", err.Error()),
|
||||
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
flowState.ConsecutiveCorrections = 0
|
||||
decision.Speak = pickExecuteVisibleSpeak(
|
||||
decision.Speak,
|
||||
output.parsedAfterText,
|
||||
output.parsedBeforeText,
|
||||
decision,
|
||||
)
|
||||
decision.Speak = normalizeSpeak(decision.Speak)
|
||||
|
||||
if decision.Action == agentmodel.ExecuteActionConfirm &&
|
||||
decision.ToolCall != nil &&
|
||||
input.ToolRegistry != nil &&
|
||||
!input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||
decision.Action = agentmodel.ExecuteActionContinue
|
||||
}
|
||||
|
||||
if decision.Action == agentmodel.ExecuteActionContinue &&
|
||||
decision.ToolCall != nil &&
|
||||
agenttools.IsContextManagementTool(decision.ToolCall.Name) {
|
||||
decision.Speak = ""
|
||||
}
|
||||
|
||||
if !output.speakStreamed && strings.TrimSpace(decision.Speak) != "" {
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
decision.Speak,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return fmt.Errorf("执行回答补发失败: %w", emitErr)
|
||||
}
|
||||
output.speakStreamed = true
|
||||
output.firstChunk = false
|
||||
}
|
||||
|
||||
if output.speakStreamed {
|
||||
if tail := buildExecuteNormalizedSpeakTail(output.streamedSpeak, decision.Speak); tail != "" {
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
tail,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return fmt.Errorf("执行回答尾段补发失败: %w", emitErr)
|
||||
}
|
||||
output.firstChunk = false
|
||||
}
|
||||
}
|
||||
|
||||
if flowState.HasPlan() &&
|
||||
(decision.Action == agentmodel.ExecuteActionNextPlan ||
|
||||
decision.Action == agentmodel.ExecuteActionDone) {
|
||||
if strings.TrimSpace(decision.GoalCheck) == "" {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次 goal_check 为空,终止执行", flowState.ConsecutiveCorrections)
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf("执行校验:action=%s 缺少 goal_check,已请求模型重试。", decision.Action),
|
||||
false,
|
||||
)
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("你输出了 action=%s,但 goal_check 为空。", decision.Action),
|
||||
fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
askUserHistoryAppended := false
|
||||
if strings.TrimSpace(decision.Speak) != "" {
|
||||
isConfirmWithCard := decision.Action == agentmodel.ExecuteActionConfirm && !input.AlwaysExecute
|
||||
isAskUser := decision.Action == agentmodel.ExecuteActionAskUser
|
||||
isAbort := decision.Action == agentmodel.ExecuteActionAbort
|
||||
|
||||
if !isConfirmWithCard && !isAskUser && !isAbort {
|
||||
msg := schema.AssistantMessage(decision.Speak, nil)
|
||||
agentshared.PersistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
if !isAbort {
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: decision.Speak,
|
||||
})
|
||||
if isAskUser {
|
||||
askUserHistoryAppended = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch decision.Action {
|
||||
case agentmodel.ExecuteActionContinue:
|
||||
if decision.ToolCall != nil {
|
||||
if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
log.Printf(
|
||||
"[WARN] execute 决策协议违背 chat=%s round=%d action=continue tool=%s consecutive=%d/%d",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
strings.TrimSpace(decision.ToolCall.Name),
|
||||
flowState.ConsecutiveCorrections,
|
||||
maxConsecutiveCorrections,
|
||||
)
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次输出 continue+写工具,终止执行", flowState.ConsecutiveCorrections)
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf(
|
||||
"执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;所有写工具都必须使用 action=confirm。",
|
||||
strings.TrimSpace(decision.ToolCall.Name),
|
||||
),
|
||||
false,
|
||||
)
|
||||
llmOutput := decision.Speak
|
||||
if strings.TrimSpace(llmOutput) == "" {
|
||||
llmOutput = decision.Reason
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
llmOutput,
|
||||
fmt.Sprintf("你输出了 action=continue,但同时提供了 %q 这个写工具。", decision.ToolCall.Name),
|
||||
"所有写工具都必须使用 action=confirm,并放在同一个 tool_call 中;continue 仅用于读工具。如果写操作尚未执行,请直接回发 confirm。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) {
|
||||
runtimeState.OpenAskUserInteraction(
|
||||
uuid.NewString(),
|
||||
buildInfeasibleNegotiationQuestion(flowState),
|
||||
strings.TrimSpace(input.ResumeNode),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return executeToolCall(
|
||||
ctx,
|
||||
flowState,
|
||||
conversationContext,
|
||||
decision.ToolCall,
|
||||
emitter,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(decision.Speak) == "" && strings.TrimSpace(decision.Reason) != "" {
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: decision.Reason,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionAskUser:
|
||||
question := resolveExecuteAskUserText(decision)
|
||||
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserSpeakStreamed, output.speakStreamed)
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserHistoryAppended, askUserHistoryAppended)
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionConfirm:
|
||||
if decision.ToolCall != nil && shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) {
|
||||
runtimeState.OpenAskUserInteraction(
|
||||
uuid.NewString(),
|
||||
buildInfeasibleNegotiationQuestion(flowState),
|
||||
strings.TrimSpace(input.ResumeNode),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if input.AlwaysExecute && decision.ToolCall != nil {
|
||||
return executeToolCall(
|
||||
ctx,
|
||||
flowState,
|
||||
conversationContext,
|
||||
decision.ToolCall,
|
||||
emitter,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
)
|
||||
}
|
||||
return handleExecuteActionConfirm(decision, runtimeState, flowState)
|
||||
|
||||
case agentmodel.ExecuteActionNextPlan:
|
||||
if !flowState.AdvanceStep() {
|
||||
flowState.Done()
|
||||
}
|
||||
appendExecuteStepAdvancedMarker(conversationContext)
|
||||
syncExecutePinnedContext(conversationContext, flowState)
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionDone:
|
||||
flowState.Done()
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionAbort:
|
||||
return handleExecuteActionAbort(decision, flowState)
|
||||
|
||||
default:
|
||||
llmOutput := decision.Speak
|
||||
if strings.TrimSpace(llmOutput) == "" {
|
||||
llmOutput = decision.Reason
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
llmOutput,
|
||||
fmt.Sprintf("你输出的 action %q 不是合法的执行动作。", decision.Action),
|
||||
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
119
backend/services/agent/node/execute/action_text.go
Normal file
119
backend/services/agent/node/execute/action_text.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
func resolveExecuteAskUserText(decision *agentmodel.ExecuteDecision) string {
|
||||
if decision == nil {
|
||||
return "执行过程中遇到不确定的情况,需要向你确认。"
|
||||
}
|
||||
if strings.TrimSpace(decision.Speak) != "" {
|
||||
return strings.TrimSpace(decision.Speak)
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) != "" {
|
||||
return strings.TrimSpace(decision.Reason)
|
||||
}
|
||||
return "执行过程中遇到不确定的情况,需要向你确认。"
|
||||
}
|
||||
|
||||
func pickExecuteVisibleSpeak(
|
||||
streamed string,
|
||||
afterText string,
|
||||
beforeText string,
|
||||
decision *agentmodel.ExecuteDecision,
|
||||
) string {
|
||||
if text := strings.TrimSpace(streamed); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := strings.TrimSpace(afterText); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := strings.TrimSpace(beforeText); text != "" {
|
||||
return text
|
||||
}
|
||||
return buildExecuteSpeakWithFallback(decision)
|
||||
}
|
||||
|
||||
func buildExecuteSpeakWithFallback(decision *agentmodel.ExecuteDecision) string {
|
||||
if decision == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
speak := strings.TrimSpace(decision.Speak)
|
||||
if speak != "" {
|
||||
return speak
|
||||
}
|
||||
|
||||
switch decision.Action {
|
||||
case agentmodel.ExecuteActionContinue,
|
||||
agentmodel.ExecuteActionAskUser,
|
||||
agentmodel.ExecuteActionConfirm:
|
||||
if reason := strings.TrimSpace(decision.Reason); reason != "" {
|
||||
return reason
|
||||
}
|
||||
switch decision.Action {
|
||||
case agentmodel.ExecuteActionAskUser:
|
||||
return "我还缺少一条关键信息,想先向你确认。"
|
||||
case agentmodel.ExecuteActionConfirm:
|
||||
return "我先整理好这一步操作,等待你的确认。"
|
||||
default:
|
||||
return "我先继续这一步处理,马上给你结果。"
|
||||
}
|
||||
default:
|
||||
return speak
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecuteActionConfirm(
|
||||
decision *agentmodel.ExecuteDecision,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
) error {
|
||||
toolCall := decision.ToolCall
|
||||
|
||||
argsJSON := ""
|
||||
if toolCall.Arguments != nil {
|
||||
if raw, err := json.Marshal(toolCall.Arguments); err == nil {
|
||||
argsJSON = string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
runtimeState.PendingConfirmTool = &agentmodel.PendingToolCallSnapshot{
|
||||
ToolName: toolCall.Name,
|
||||
ArgsJSON: argsJSON,
|
||||
Summary: strings.TrimSpace(decision.Speak),
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseWaitingConfirm
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleExecuteActionAbort(
|
||||
decision *agentmodel.ExecuteDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) error {
|
||||
if decision == nil || decision.Abort == nil {
|
||||
return fmt.Errorf("abort 动作缺少终止信息")
|
||||
}
|
||||
if flowState == nil {
|
||||
return fmt.Errorf("abort 动作缺少流程状态")
|
||||
}
|
||||
|
||||
internalReason := strings.TrimSpace(decision.Abort.InternalReason)
|
||||
if internalReason == "" {
|
||||
internalReason = strings.TrimSpace(decision.Reason)
|
||||
}
|
||||
|
||||
flowState.Abort(
|
||||
executeStageName,
|
||||
decision.Abort.Code,
|
||||
decision.Abort.UserMessage,
|
||||
internalReason,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
162
backend/services/agent/node/execute/args.go
Normal file
162
backend/services/agent/node/execute/args.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func intSliceToSet(values []int) map[int]struct{} {
|
||||
result := make(map[int]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readIntAnyFromMap(args map[string]any, keys ...string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
if args == nil {
|
||||
continue
|
||||
}
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if value, ok := parseAnyToInt(raw); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func readIntSliceAnyFromMap(args map[string]any, keys ...string) []int {
|
||||
for _, key := range keys {
|
||||
if args == nil {
|
||||
continue
|
||||
}
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
values := parseAnyToIntSlice(raw)
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readStringAnyFromMap(args map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if args == nil {
|
||||
continue
|
||||
}
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if text, ok := raw.(string); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseAnyToInt(value any) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int8:
|
||||
return int(v), true
|
||||
case int16:
|
||||
return int(v), true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float32:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
if iv, err := v.Int64(); err == nil {
|
||||
return int(iv), true
|
||||
}
|
||||
if fv, err := v.Float64(); err == nil {
|
||||
return int(fv), true
|
||||
}
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
iv, err := strconv.Atoi(text)
|
||||
if err == nil {
|
||||
return iv, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseAnyToIntSlice(value any) []int {
|
||||
switch values := value.(type) {
|
||||
case []int:
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]int, 0, len(values))
|
||||
for _, item := range values {
|
||||
iv, ok := parseAnyToInt(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result = append(result, iv)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseAnyToStringSlice(value any) []string {
|
||||
switch values := value.(type) {
|
||||
case []string:
|
||||
result := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
text := strings.TrimSpace(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", item))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func truncateText(text string, maxLen int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return text[:maxLen]
|
||||
}
|
||||
return text[:maxLen-3] + "..."
|
||||
}
|
||||
157
backend/services/agent/node/execute/context.go
Normal file
157
backend/services/agent/node/execute/context.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
planCurrentStepKey = "current_step"
|
||||
planCurrentStepTitle = "当前步骤"
|
||||
)
|
||||
|
||||
func prepareExecuteNodeInput(input ExecuteNodeInput) (*agentmodel.AgentRuntimeState, *agentmodel.ConversationContext, *agentstream.ChunkEmitter, error) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("execute node: runtime state 不能为空")
|
||||
}
|
||||
if input.Client == nil {
|
||||
return nil, nil, nil, fmt.Errorf("execute node: execute client 未注入")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix())
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
|
||||
func syncExecutePinnedContext(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
) {
|
||||
if conversationContext == nil || flowState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
execContent := buildExecuteContextPinnedMarkdown(flowState)
|
||||
if strings.TrimSpace(execContent) != "" {
|
||||
conversationContext.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: executePinnedKey,
|
||||
Title: "执行上下文",
|
||||
Content: execContent,
|
||||
})
|
||||
}
|
||||
|
||||
if !flowState.HasPlan() {
|
||||
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||
return
|
||||
}
|
||||
|
||||
step, ok := flowState.CurrentPlanStep()
|
||||
if !ok {
|
||||
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||
return
|
||||
}
|
||||
|
||||
current, total := flowState.PlanProgress()
|
||||
title := strings.TrimSpace(planCurrentStepTitle)
|
||||
if title == "" {
|
||||
title = "当前步骤"
|
||||
}
|
||||
conversationContext.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: planCurrentStepKey,
|
||||
Title: title,
|
||||
Content: buildCurrentPlanStepPinnedMarkdown(step, current, total),
|
||||
})
|
||||
}
|
||||
|
||||
func appendExecuteStepAdvancedMarker(conversationContext *agentmodel.ConversationContext) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
last := history[len(history)-1]
|
||||
if last != nil && last.Extra != nil {
|
||||
if kind, ok := last.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindStepAdvanced {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
Extra: map[string]any{
|
||||
executeHistoryKindKey: executeHistoryKindStepAdvanced,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func buildExecuteContextPinnedMarkdown(flowState *agentmodel.CommonState) string {
|
||||
if flowState == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 8)
|
||||
if flowState.HasPlan() {
|
||||
lines = append(lines, "执行模式:计划执行(按步骤推进)")
|
||||
current, total := flowState.PlanProgress()
|
||||
lines = append(lines, fmt.Sprintf("计划进度:第 %d/%d 步", current, total))
|
||||
|
||||
if step, ok := flowState.CurrentPlanStep(); ok {
|
||||
lines = append(lines, "当前步骤:"+compactExecutePinnedText(step.Content))
|
||||
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||
if doneWhen != "" {
|
||||
lines = append(lines, "完成判定(done_when):"+doneWhen)
|
||||
}
|
||||
lines = append(lines, "动作纪律:未满足 done_when 禁止 next_plan;满足后优先 next_plan。")
|
||||
} else {
|
||||
lines = append(lines, "当前步骤:不可读(可能已执行完成)")
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, "执行模式:自由执行(无预定义步骤)")
|
||||
}
|
||||
|
||||
if flowState.MaxRounds > 0 {
|
||||
lines = append(lines, fmt.Sprintf("轮次预算:%d/%d", flowState.RoundUsed, flowState.MaxRounds))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func buildCurrentPlanStepPinnedMarkdown(step agentmodel.PlanStep, current, total int) string {
|
||||
lines := make([]string, 0, 4)
|
||||
lines = append(lines, fmt.Sprintf("步骤进度:第 %d/%d 步", current, total))
|
||||
|
||||
content := compactExecutePinnedText(step.Content)
|
||||
if content == "" {
|
||||
content = "(空)"
|
||||
}
|
||||
lines = append(lines, "步骤内容:"+content)
|
||||
|
||||
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||
if doneWhen != "" {
|
||||
lines = append(lines, "完成判定:"+doneWhen)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func compactExecutePinnedText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\n", ";")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
150
backend/services/agent/node/execute/run.go
Normal file
150
backend/services/agent/node/execute/run.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
executeStageName = "execute"
|
||||
executeStatusBlockID = "execute.status"
|
||||
executeSpeakBlockID = "execute.speak"
|
||||
executePinnedKey = "execution_context"
|
||||
toolAnalyzeHealth = "analyze_health"
|
||||
executeHistoryKindKey = "newagent_history_kind"
|
||||
executeHistoryKindStepAdvanced = "execute_step_advanced"
|
||||
|
||||
maxConsecutiveCorrections = 3
|
||||
)
|
||||
|
||||
type ExecuteNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
ResumeNode string
|
||||
ToolRegistry *agenttools.ToolRegistry
|
||||
ScheduleState *schedule.ScheduleState
|
||||
CompactionStore agentmodel.CompactionStore
|
||||
WriteSchedulePreview agentmodel.WriteSchedulePreviewFunc
|
||||
OriginalScheduleState *schedule.ScheduleState
|
||||
AlwaysExecute bool
|
||||
ThinkingEnabled bool
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
type ExecuteRoundObservation struct {
|
||||
Round int `json:"round"`
|
||||
StepIndex int `json:"step_index"`
|
||||
GoalCheck string `json:"goal_check,omitempty"`
|
||||
Decision string `json:"decision,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolParams string `json:"tool_params,omitempty"`
|
||||
ToolSuccess bool `json:"tool_success"`
|
||||
ToolResult string `json:"tool_result,omitempty"`
|
||||
}
|
||||
|
||||
func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareExecuteNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
applyPendingContextHook(flowState)
|
||||
|
||||
if runtimeState.PendingConfirmTool != nil {
|
||||
return executePendingTool(
|
||||
ctx,
|
||||
runtimeState,
|
||||
conversationContext,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.OriginalScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
emitter,
|
||||
)
|
||||
}
|
||||
|
||||
if input.ScheduleState != nil && flowState.RoundUsed == 0 {
|
||||
schedule.ResetTaskProcessingQueue(input.ScheduleState)
|
||||
}
|
||||
|
||||
syncExecutePinnedContext(conversationContext, flowState)
|
||||
|
||||
if flowState.HasCurrentPlanStep() {
|
||||
current, total := flowState.PlanProgress()
|
||||
currentStep, _ := flowState.CurrentPlanStep()
|
||||
if err := emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf("正在执行第 %d/%d 步:%s", current, total, truncateText(currentStep.Content, 60)),
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("执行阶段状态推送失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
"正在处理你的请求...",
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("执行阶段状态推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !flowState.NextRound() {
|
||||
flowState.Exhaust(
|
||||
executeStageName,
|
||||
"本轮执行已达到安全轮次上限,当前先停止继续操作。如需继续,我可以在你确认后接着处理剩余步骤。",
|
||||
"execute rounds exhausted before task completion",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
messages := agentprompt.BuildExecuteMessages(flowState, conversationContext)
|
||||
messages = agentshared.CompactUnifiedMessagesIfNeeded(ctx, messages, agentshared.UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: executeStageName,
|
||||
StatusBlockID: executeStatusBlockID,
|
||||
})
|
||||
|
||||
agentshared.LogNodeLLMContext(executeStageName, "decision", flowState, messages)
|
||||
|
||||
decisionOutput, err := collectExecuteDecisionFromLLM(
|
||||
ctx,
|
||||
input,
|
||||
flowState,
|
||||
conversationContext,
|
||||
emitter,
|
||||
messages,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return handleExecuteDecision(
|
||||
ctx,
|
||||
input,
|
||||
runtimeState,
|
||||
flowState,
|
||||
conversationContext,
|
||||
emitter,
|
||||
decisionOutput,
|
||||
)
|
||||
}
|
||||
332
backend/services/agent/node/execute/state_snapshot.go
Normal file
332
backend/services/agent/node/execute/state_snapshot.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
)
|
||||
|
||||
func shouldForceFeasibilityNegotiation(
|
||||
flowState *agentmodel.CommonState,
|
||||
registry *agenttools.ToolRegistry,
|
||||
toolName string,
|
||||
) bool {
|
||||
if flowState == nil || registry == nil {
|
||||
return false
|
||||
}
|
||||
if !flowState.HealthCheckDone || flowState.HealthIsFeasible {
|
||||
return false
|
||||
}
|
||||
if !registry.IsWriteTool(toolName) || !registry.RequiresScheduleState(toolName) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildInfeasibleNegotiationQuestion(flowState *agentmodel.CommonState) string {
|
||||
capacityGap := 0
|
||||
reasonCode := "capacity_insufficient"
|
||||
if flowState != nil {
|
||||
capacityGap = flowState.HealthCapacityGap
|
||||
if strings.TrimSpace(flowState.HealthReasonCode) != "" {
|
||||
reasonCode = strings.TrimSpace(flowState.HealthReasonCode)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"当前计划不可行:analyze_health 判断当前约束不可行(capacity_gap=%d,reason=%s)。在继续写操作前,请先与用户协商:扩展时间窗、放宽约束、缩减范围或预算,或接受风险收口。",
|
||||
capacityGap,
|
||||
reasonCode,
|
||||
)
|
||||
}
|
||||
|
||||
func buildInfeasibleBlockedResult(flowState *agentmodel.CommonState) string {
|
||||
capacityGap := 0
|
||||
reasonCode := "capacity_insufficient"
|
||||
if flowState != nil {
|
||||
capacityGap = flowState.HealthCapacityGap
|
||||
if strings.TrimSpace(flowState.HealthReasonCode) != "" {
|
||||
reasonCode = strings.TrimSpace(flowState.HealthReasonCode)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"已阻断本次写操作:analyze_health 判定当前约束不可行(capacity_gap=%d,reason=%s)。请先与用户协商:扩展时间窗 / 放宽约束 / 缩减范围或预算 / 接受风险收口。",
|
||||
capacityGap,
|
||||
reasonCode,
|
||||
)
|
||||
}
|
||||
|
||||
type contextToolsResultEnvelope struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
All bool `json:"all,omitempty"`
|
||||
}
|
||||
|
||||
type analyzeHealthResultEnvelope struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"`
|
||||
Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"`
|
||||
}
|
||||
|
||||
type analyzeHealthFeasibilityBrief struct {
|
||||
IsFeasible bool `json:"is_feasible"`
|
||||
CapacityGap int `json:"capacity_gap"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type analyzeHealthDecisionBrief struct {
|
||||
ShouldContinueOptimize bool `json:"should_continue_optimize"`
|
||||
PrimaryProblem string `json:"primary_problem,omitempty"`
|
||||
RecommendedOperation string `json:"recommended_operation,omitempty"`
|
||||
IsForcedImperfection bool `json:"is_forced_imperfection"`
|
||||
ImprovementSignal string `json:"improvement_signal,omitempty"`
|
||||
}
|
||||
|
||||
type upsertTaskClassResultEnvelope struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Validation *upsertTaskClassValidationPart `json:"validation,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
type upsertTaskClassValidationPart struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []string `json:"issues"`
|
||||
}
|
||||
|
||||
func updateActiveToolDomainSnapshot(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !agenttools.IsContextManagementTool(toolName) {
|
||||
return
|
||||
}
|
||||
|
||||
var envelope contextToolsResultEnvelope
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
return
|
||||
}
|
||||
if !envelope.Success {
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(toolName) {
|
||||
case agenttools.ToolNameContextToolsAdd:
|
||||
domain := agenttools.NormalizeToolDomain(envelope.Domain)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
nextPacks := agenttools.ResolveEffectiveToolPacks(domain, envelope.Packs)
|
||||
mode := strings.ToLower(strings.TrimSpace(envelope.Mode))
|
||||
if mode == "merge" && agenttools.NormalizeToolDomain(flowState.ActiveToolDomain) == domain {
|
||||
merged := make([]string, 0, len(flowState.ActiveToolPacks)+len(nextPacks))
|
||||
seen := make(map[string]struct{}, len(flowState.ActiveToolPacks)+len(nextPacks))
|
||||
current := agenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
for _, pack := range current {
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
merged = append(merged, pack)
|
||||
}
|
||||
for _, pack := range nextPacks {
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
merged = append(merged, pack)
|
||||
}
|
||||
nextPacks = merged
|
||||
}
|
||||
flowState.ActiveToolDomain = domain
|
||||
flowState.ActiveToolPacks = nextPacks
|
||||
case agenttools.ToolNameContextToolsRemove:
|
||||
if envelope.All {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
domain := agenttools.NormalizeToolDomain(envelope.Domain)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
currentDomain := agenttools.NormalizeToolDomain(flowState.ActiveToolDomain)
|
||||
if currentDomain != domain {
|
||||
return
|
||||
}
|
||||
|
||||
removedPacks := agenttools.NormalizeToolPacks(domain, envelope.Packs)
|
||||
if len(removedPacks) == 0 {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
|
||||
currentEffective := agenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
if len(currentEffective) == 0 {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
|
||||
removedSet := make(map[string]struct{}, len(removedPacks))
|
||||
for _, pack := range removedPacks {
|
||||
removedSet[pack] = struct{}{}
|
||||
}
|
||||
remaining := make([]string, 0, len(currentEffective))
|
||||
for _, pack := range currentEffective {
|
||||
if _, shouldRemove := removedSet[pack]; shouldRemove {
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, pack)
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
flowState.ActiveToolPacks = remaining
|
||||
}
|
||||
}
|
||||
|
||||
func updateHealthFeasibilitySnapshot(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) {
|
||||
return
|
||||
}
|
||||
|
||||
flowState.HealthCheckDone = false
|
||||
flowState.HealthIsFeasible = true
|
||||
flowState.HealthCapacityGap = 0
|
||||
flowState.HealthReasonCode = ""
|
||||
|
||||
var envelope analyzeHealthResultEnvelope
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
return
|
||||
}
|
||||
if !envelope.Success || envelope.Feasibility == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState.HealthCheckDone = true
|
||||
flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible
|
||||
flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap
|
||||
flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode)
|
||||
}
|
||||
|
||||
func updateTaskClassUpsertSnapshot(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), "upsert_task_class") {
|
||||
return
|
||||
}
|
||||
|
||||
flowState.TaskClassUpsertLastTried = true
|
||||
flowState.TaskClassUpsertLastSuccess = false
|
||||
flowState.TaskClassUpsertLastIssues = nil
|
||||
|
||||
var envelope upsertTaskClassResultEnvelope
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
flowState.TaskClassUpsertConsecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
success := envelope.Success
|
||||
issues := make([]string, 0)
|
||||
if envelope.Validation != nil {
|
||||
issues = append(issues, parseAnyToStringSlice(any(envelope.Validation.Issues))...)
|
||||
if !envelope.Validation.OK {
|
||||
success = false
|
||||
}
|
||||
}
|
||||
if !success && strings.TrimSpace(envelope.Error) != "" && len(issues) == 0 {
|
||||
issues = append(issues, strings.TrimSpace(envelope.Error))
|
||||
}
|
||||
issues = uniqueNonEmptyStrings(issues)
|
||||
|
||||
flowState.TaskClassUpsertLastSuccess = success
|
||||
flowState.TaskClassUpsertLastIssues = issues
|
||||
if success {
|
||||
flowState.TaskClassUpsertConsecutiveFailures = 0
|
||||
return
|
||||
}
|
||||
flowState.TaskClassUpsertConsecutiveFailures++
|
||||
}
|
||||
|
||||
func uniqueNonEmptyStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[text]; exists {
|
||||
continue
|
||||
}
|
||||
seen[text] = struct{}{}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func updateHealthSnapshotV2(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) {
|
||||
return
|
||||
}
|
||||
|
||||
prevSignal := strings.TrimSpace(flowState.HealthImprovementSignal)
|
||||
flowState.HealthCheckDone = false
|
||||
flowState.HealthIsFeasible = true
|
||||
flowState.HealthCapacityGap = 0
|
||||
flowState.HealthReasonCode = ""
|
||||
flowState.HealthShouldContinueOptimize = false
|
||||
flowState.HealthTightnessLevel = ""
|
||||
flowState.HealthPrimaryProblem = ""
|
||||
flowState.HealthRecommendedOperation = ""
|
||||
flowState.HealthIsForcedImperfection = false
|
||||
flowState.HealthImprovementSignal = ""
|
||||
|
||||
var envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"`
|
||||
Metrics struct {
|
||||
Tightness *struct {
|
||||
TightnessLevel string `json:"tightness_level"`
|
||||
} `json:"tightness,omitempty"`
|
||||
} `json:"metrics"`
|
||||
Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
flowState.HealthStagnationCount = 0
|
||||
return
|
||||
}
|
||||
if !envelope.Success || envelope.Feasibility == nil {
|
||||
flowState.HealthStagnationCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
flowState.HealthCheckDone = true
|
||||
flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible
|
||||
flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap
|
||||
flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode)
|
||||
if envelope.Metrics.Tightness != nil {
|
||||
flowState.HealthTightnessLevel = strings.TrimSpace(envelope.Metrics.Tightness.TightnessLevel)
|
||||
}
|
||||
if envelope.Decision != nil {
|
||||
flowState.HealthShouldContinueOptimize = envelope.Decision.ShouldContinueOptimize
|
||||
flowState.HealthPrimaryProblem = strings.TrimSpace(envelope.Decision.PrimaryProblem)
|
||||
flowState.HealthRecommendedOperation = strings.TrimSpace(envelope.Decision.RecommendedOperation)
|
||||
flowState.HealthIsForcedImperfection = envelope.Decision.IsForcedImperfection
|
||||
flowState.HealthImprovementSignal = strings.TrimSpace(envelope.Decision.ImprovementSignal)
|
||||
}
|
||||
if signal := strings.TrimSpace(flowState.HealthImprovementSignal); signal != "" && prevSignal != "" && signal == prevSignal {
|
||||
flowState.HealthStagnationCount++
|
||||
return
|
||||
}
|
||||
flowState.HealthStagnationCount = 0
|
||||
}
|
||||
418
backend/services/agent/node/execute/tool_runtime.go
Normal file
418
backend/services/agent/node/execute/tool_runtime.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func appendToolCallResultHistory(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
toolName string,
|
||||
args map[string]any,
|
||||
result agenttools.ToolExecutionResult,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
argsJSON := "{}"
|
||||
if args != nil {
|
||||
if raw, err := json.Marshal(args); err == nil {
|
||||
argsJSON = string(raw)
|
||||
}
|
||||
}
|
||||
toolCallID := uuid.NewString()
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
ID: toolCallID,
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: toolName,
|
||||
Arguments: argsJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: result.ObservationText,
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
})
|
||||
}
|
||||
|
||||
func executeToolCall(
|
||||
ctx context.Context,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
toolCall *agentmodel.ToolCallIntent,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
registry *agenttools.ToolRegistry,
|
||||
scheduleState *schedule.ScheduleState,
|
||||
writePreview agentmodel.WriteSchedulePreviewFunc,
|
||||
) error {
|
||||
if toolCall == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolName := strings.TrimSpace(toolCall.Name)
|
||||
if toolName == "" {
|
||||
return fmt.Errorf("工具调用缺少工具名称")
|
||||
}
|
||||
|
||||
if err := emitter.EmitToolCallStart(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
toolName,
|
||||
buildToolCallStartSummary(toolName, toolCall.Arguments),
|
||||
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("工具调用开始事件发送失败: %w", err)
|
||||
}
|
||||
|
||||
if registry == nil {
|
||||
return fmt.Errorf("工具注册表未注入")
|
||||
}
|
||||
if scheduleState == nil && registry.RequiresScheduleState(toolName) {
|
||||
return fmt.Errorf("日程状态未加载,无法执行工具 %q", toolName)
|
||||
}
|
||||
if registry.IsToolTemporarilyDisabled(toolName) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次调用临时禁用工具,终止执行: %s",
|
||||
flowState.ConsecutiveCorrections, toolName)
|
||||
}
|
||||
blockedText := buildTemporarilyDisabledToolResult(toolName)
|
||||
blockedResult := agenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "tool_temporarily_disabled", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("工具 %q 当前暂时禁用。", toolName),
|
||||
"请改用 move/swap/batch_move/unplace 等排程微调工具继续推进。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !registry.HasTool(toolName) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次调用未知工具,终止执行: %s;可用工具:%s。",
|
||||
flowState.ConsecutiveCorrections, toolName, strings.Join(registry.ToolNames(), "、"))
|
||||
}
|
||||
log.Printf("[WARN] execute 工具名不合法 chat=%s round=%d tool=%s consecutive=%d/%d available=%v",
|
||||
flowState.ConversationID, flowState.RoundUsed, toolName,
|
||||
flowState.ConsecutiveCorrections, maxConsecutiveCorrections, registry.ToolNames())
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("你调用的工具 %q 不存在。", toolName),
|
||||
fmt.Sprintf("可用工具:%s。请检查拼写后重试。", strings.Join(registry.ToolNames(), "、")),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !isToolVisibleForCurrentExecuteMode(flowState, registry, toolName) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次调用未激活工具,终止执行: %s(active_domain=%q active_packs=%v)",
|
||||
flowState.ConsecutiveCorrections,
|
||||
toolName,
|
||||
flowState.ActiveToolDomain,
|
||||
agenttools.ResolveEffectiveToolPacks(flowState.ActiveToolDomain, flowState.ActiveToolPacks))
|
||||
}
|
||||
|
||||
addHint := `请先调用 context_tools_add 激活目标工具域后再继续。`
|
||||
if flowState != nil && flowState.ActiveOptimizeOnly {
|
||||
addHint = `当前处于“粗排后主动优化专用模式”,只允许使用 analyze_health、move、swap;不要再尝试 query_target_tasks / query_available_slots 等全窗搜索工具。`
|
||||
} else if domain, pack, ok := agenttools.ResolveToolDomainPack(toolName); ok {
|
||||
if agenttools.IsFixedToolPack(domain, pack) {
|
||||
addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s"。`, domain)
|
||||
} else {
|
||||
addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s", packs=["%s"]。`, domain, pack)
|
||||
}
|
||||
}
|
||||
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("你调用的工具 %q 当前不在已激活工具域内。", toolName),
|
||||
addHint,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
|
||||
blockedText := buildInfeasibleBlockedResult(flowState)
|
||||
blockedResult := agenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "health_negotiation_required", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
if !registry.RequiresScheduleState(toolName) {
|
||||
if toolCall.Arguments == nil {
|
||||
toolCall.Arguments = make(map[string]any)
|
||||
}
|
||||
toolCall.Arguments["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
|
||||
result = agenttools.EnsureToolResultDefaults(result, toolCall.Arguments)
|
||||
updateHealthSnapshotV2(flowState, toolName, result.ObservationText)
|
||||
updateTaskClassUpsertSnapshot(flowState, toolName, result.ObservationText)
|
||||
updateActiveToolDomainSnapshot(flowState, toolName, result.ObservationText)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
"[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
toolName,
|
||||
marshalArgsForDebug(toolCall.Arguments),
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
flattenForLog(result.ObservationText),
|
||||
)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, toolCall.Arguments)
|
||||
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
|
||||
|
||||
if registry.IsScheduleMutationTool(toolName) {
|
||||
flowState.HasScheduleWriteOps = true
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, toolName, writePreview)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyPendingContextHook(flowState *agentmodel.CommonState) {
|
||||
if flowState == nil || flowState.PendingContextHook == nil {
|
||||
return
|
||||
}
|
||||
hook := flowState.PendingContextHook
|
||||
domain := agenttools.NormalizeToolDomain(hook.Domain)
|
||||
if domain == "" {
|
||||
flowState.PendingContextHook = nil
|
||||
return
|
||||
}
|
||||
flowState.ActiveToolDomain = domain
|
||||
flowState.ActiveToolPacks = agenttools.ResolveEffectiveToolPacks(domain, hook.Packs)
|
||||
flowState.PendingContextHook = nil
|
||||
}
|
||||
|
||||
func isToolVisibleForCurrentExecuteMode(
|
||||
flowState *agentmodel.CommonState,
|
||||
registry *agenttools.ToolRegistry,
|
||||
toolName string,
|
||||
) bool {
|
||||
if registry == nil {
|
||||
return false
|
||||
}
|
||||
activeDomain := ""
|
||||
var activePacks []string
|
||||
if flowState != nil {
|
||||
activeDomain = flowState.ActiveToolDomain
|
||||
activePacks = flowState.ActiveToolPacks
|
||||
}
|
||||
if !registry.IsToolVisibleInDomain(activeDomain, activePacks, toolName) {
|
||||
return false
|
||||
}
|
||||
if flowState != nil && flowState.ActiveOptimizeOnly && !agenttools.IsToolAllowedInActiveOptimize(toolName) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildTemporarilyDisabledToolResult(toolName string) string {
|
||||
return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等排程微调工具。", strings.TrimSpace(toolName))
|
||||
}
|
||||
|
||||
func executePendingTool(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
registry *agenttools.ToolRegistry,
|
||||
scheduleState *schedule.ScheduleState,
|
||||
originalState *schedule.ScheduleState,
|
||||
writePreview agentmodel.WriteSchedulePreviewFunc,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
pending := runtimeState.PendingConfirmTool
|
||||
if pending == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(pending.ArgsJSON), &args); err != nil {
|
||||
return fmt.Errorf("解析待确认工具参数失败: %w", err)
|
||||
}
|
||||
|
||||
if err := emitter.EmitToolCallStart(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
pending.ToolName,
|
||||
buildToolCallStartSummary(pending.ToolName, args),
|
||||
buildToolArgumentsPreviewCN(args),
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("工具调用开始事件发送失败: %w", err)
|
||||
}
|
||||
|
||||
if scheduleState == nil {
|
||||
return fmt.Errorf("日程状态未加载,无法执行已确认的写工具 %s", pending.ToolName)
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if registry.IsToolTemporarilyDisabled(pending.ToolName) {
|
||||
blockedText := buildTemporarilyDisabledToolResult(pending.ToolName)
|
||||
blockedResult := agenttools.BlockedResult(pending.ToolName, args, blockedText, "tool_temporarily_disabled", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
|
||||
blockedText := buildInfeasibleBlockedResult(flowState)
|
||||
blockedResult := agenttools.BlockedResult(pending.ToolName, args, blockedText, "health_negotiation_required", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
if !registry.RequiresScheduleState(pending.ToolName) {
|
||||
if args == nil {
|
||||
args = make(map[string]any)
|
||||
}
|
||||
args["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, pending.ToolName, args)
|
||||
result = agenttools.EnsureToolResultDefaults(result, args)
|
||||
updateHealthSnapshotV2(flowState, pending.ToolName, result.ObservationText)
|
||||
updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result.ObservationText)
|
||||
updateActiveToolDomainSnapshot(flowState, pending.ToolName, result.ObservationText)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
pending.ToolName,
|
||||
marshalArgsForDebug(args),
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
flattenForLog(result.ObservationText),
|
||||
)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, args)
|
||||
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
|
||||
|
||||
if registry.IsScheduleMutationTool(pending.ToolName) {
|
||||
flowState.HasScheduleWriteOps = true
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)
|
||||
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryWritePreviewAfterWriteTool(
|
||||
ctx context.Context,
|
||||
flowState *agentmodel.CommonState,
|
||||
scheduleState *schedule.ScheduleState,
|
||||
registry *agenttools.ToolRegistry,
|
||||
toolName string,
|
||||
writePreview agentmodel.WriteSchedulePreviewFunc,
|
||||
) {
|
||||
if flowState == nil || scheduleState == nil || registry == nil || writePreview == nil {
|
||||
return
|
||||
}
|
||||
if !registry.IsScheduleMutationTool(toolName) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := writePreview(ctx, scheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil {
|
||||
log.Printf(
|
||||
"[WARN] execute realtime preview write failed chat=%s tool=%s err=%v",
|
||||
flowState.ConversationID,
|
||||
toolName,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] execute realtime preview write success chat=%s tool=%s",
|
||||
flowState.ConversationID,
|
||||
toolName,
|
||||
)
|
||||
}
|
||||
|
||||
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
|
||||
|
||||
func normalizeSpeak(speak string) string {
|
||||
speak = strings.TrimSpace(speak)
|
||||
if speak == "" {
|
||||
return speak
|
||||
}
|
||||
if !strings.Contains(speak, "\n") {
|
||||
speak = listItemRe.ReplaceAllString(speak, "$1\n$2")
|
||||
}
|
||||
return speak + "\n"
|
||||
}
|
||||
|
||||
func buildExecuteNormalizedSpeakTail(streamed, normalized string) string {
|
||||
streamed = strings.ReplaceAll(streamed, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r\n", "\n")
|
||||
if streamed == "" || normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(normalized, streamed) {
|
||||
return ""
|
||||
}
|
||||
return normalized[len(streamed):]
|
||||
}
|
||||
|
||||
func emitToolCallResultEvent(
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
blockID string,
|
||||
stage string,
|
||||
result agenttools.ToolExecutionResult,
|
||||
args map[string]any,
|
||||
) {
|
||||
if emitter == nil {
|
||||
return
|
||||
}
|
||||
result = agenttools.EnsureToolResultDefaults(result, args)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
blockID,
|
||||
stage,
|
||||
result.Tool,
|
||||
result.Status,
|
||||
result.Summary,
|
||||
result.ArgumentsPreview,
|
||||
agenttools.ToolArgumentViewToMap(result.ArgumentView),
|
||||
agenttools.ToolDisplayViewToMap(result.ResultView),
|
||||
false,
|
||||
)
|
||||
}
|
||||
420
backend/services/agent/node/execute/tool_view.go
Normal file
420
backend/services/agent/node/execute/tool_view.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
func summarizeScheduleStateForDebug(state *schedule.ScheduleState) string {
|
||||
if state == nil {
|
||||
return "state=nil"
|
||||
}
|
||||
|
||||
total := len(state.Tasks)
|
||||
pendingNoSlot := 0
|
||||
suggestedTotal := 0
|
||||
existingTotal := 0
|
||||
taskItemWithSlot := 0
|
||||
eventWithSlot := 0
|
||||
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
hasSlot := len(t.Slots) > 0
|
||||
|
||||
switch {
|
||||
case schedule.IsPendingTask(*t):
|
||||
pendingNoSlot++
|
||||
case schedule.IsSuggestedTask(*t):
|
||||
suggestedTotal++
|
||||
case schedule.IsExistingTask(*t):
|
||||
existingTotal++
|
||||
}
|
||||
|
||||
if hasSlot {
|
||||
if t.Source == "task_item" {
|
||||
taskItemWithSlot++
|
||||
}
|
||||
if t.Source == "event" {
|
||||
eventWithSlot++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"tasks=%d pending=%d suggested=%d existing=%d task_item_with_slot=%d event_with_slot=%d",
|
||||
total,
|
||||
pendingNoSlot,
|
||||
suggestedTotal,
|
||||
existingTotal,
|
||||
taskItemWithSlot,
|
||||
eventWithSlot,
|
||||
)
|
||||
}
|
||||
|
||||
func marshalArgsForDebug(args map[string]any) string {
|
||||
if len(args) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return "<marshal_error>"
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func flattenForLog(text string) string {
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
text = strings.ReplaceAll(text, "\r", " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func resolveToolEventResultStatus(result string) string {
|
||||
normalized := strings.TrimSpace(result)
|
||||
if normalized == "" {
|
||||
return "done"
|
||||
}
|
||||
if strings.Contains(normalized, "失败") {
|
||||
return "failed"
|
||||
}
|
||||
lower := strings.ToLower(normalized)
|
||||
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") {
|
||||
return "failed"
|
||||
}
|
||||
return "done"
|
||||
}
|
||||
|
||||
func buildToolEventResultSummary(result string) string {
|
||||
flat := flattenForLog(result)
|
||||
if flat == "" {
|
||||
return "工具已执行完成。"
|
||||
}
|
||||
|
||||
if summary, ok := tryExtractToolResultSummaryCN(flat); ok {
|
||||
return summary
|
||||
}
|
||||
|
||||
runes := []rune(flat)
|
||||
if len(runes) <= 48 {
|
||||
return flat
|
||||
}
|
||||
return string(runes[:48]) + "..."
|
||||
}
|
||||
|
||||
func tryExtractToolResultSummaryCN(raw string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
toolRaw := strings.TrimSpace(readStringAnyFromMap(payload, "tool"))
|
||||
toolName := resolveToolDisplayNameCN(toolRaw)
|
||||
|
||||
if strings.EqualFold(toolRaw, "upsert_task_class") {
|
||||
if summary, ok := buildUpsertTaskClassSummaryCN(payload); ok {
|
||||
return truncateToolSummaryCN(summary), true
|
||||
}
|
||||
}
|
||||
|
||||
if errText := strings.TrimSpace(readStringAnyFromMap(payload, "error", "err")); errText != "" {
|
||||
return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, errText)), true
|
||||
}
|
||||
|
||||
if success, exists := payload["success"]; exists {
|
||||
if ok, isBool := success.(bool); isBool && !ok {
|
||||
reason := strings.TrimSpace(readStringAnyFromMap(payload, "reason", "message"))
|
||||
if reason != "" {
|
||||
return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, reason)), true
|
||||
}
|
||||
return truncateToolSummaryCN(fmt.Sprintf("%s执行失败。", toolName)), true
|
||||
}
|
||||
}
|
||||
|
||||
if message := strings.TrimSpace(readStringAnyFromMap(payload, "result", "message", "reason")); message != "" {
|
||||
return truncateToolSummaryCN(message), true
|
||||
}
|
||||
|
||||
pending, hasPending := readIntAnyFromMap(payload, "pending_count")
|
||||
completed, hasCompleted := readIntAnyFromMap(payload, "completed_count")
|
||||
if hasPending || hasCompleted {
|
||||
skipped, _ := readIntAnyFromMap(payload, "skipped_count")
|
||||
return fmt.Sprintf("队列状态:待处理 %d,已完成 %d,已跳过 %d。", pending, completed, skipped), true
|
||||
}
|
||||
|
||||
if hasHead, exists := payload["has_head"]; exists {
|
||||
if b, isBool := hasHead.(bool); isBool {
|
||||
if b {
|
||||
return "已获取当前队首任务。", true
|
||||
}
|
||||
return "当前队列没有可处理任务。", true
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := payload["slot_candidates"]; ok {
|
||||
if total, exists := readIntAnyFromMap(payload, "total"); exists {
|
||||
return fmt.Sprintf("共找到 %d 个可用时段。", total), true
|
||||
}
|
||||
}
|
||||
|
||||
if toolRaw != "" {
|
||||
return fmt.Sprintf("已完成“%s”操作。", toolName), true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func buildUpsertTaskClassSummaryCN(payload map[string]any) (string, bool) {
|
||||
validationRaw, hasValidation := payload["validation"]
|
||||
if !hasValidation {
|
||||
return "", false
|
||||
}
|
||||
validation, ok := validationRaw.(map[string]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
validationOK, hasValidationOK := validation["ok"].(bool)
|
||||
issues := parseAnyToStringSlice(validation["issues"])
|
||||
|
||||
if hasValidationOK && !validationOK {
|
||||
if len(issues) > 0 {
|
||||
return fmt.Sprintf("任务类写入未通过校验:%s。", strings.Join(issues, ";")), true
|
||||
}
|
||||
return "任务类写入未通过校验,请先补齐缺失字段。", true
|
||||
}
|
||||
|
||||
success, hasSuccess := payload["success"].(bool)
|
||||
if hasSuccess && success {
|
||||
if taskClassID, ok := readIntAnyFromMap(payload, "task_class_id"); ok && taskClassID > 0 {
|
||||
return fmt.Sprintf("任务类写入成功,task_class_id=%d。", taskClassID), true
|
||||
}
|
||||
return "任务类写入成功。", true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func truncateToolSummaryCN(text string) string {
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
if len(runes) <= 48 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:48]) + "..."
|
||||
}
|
||||
|
||||
func buildToolCallStartSummary(toolName string, args map[string]any) string {
|
||||
displayName := resolveToolDisplayNameCN(toolName)
|
||||
argSummary := buildToolArgumentsPreviewCN(args)
|
||||
if argSummary == "" {
|
||||
return fmt.Sprintf("已调用工具:%s。", displayName)
|
||||
}
|
||||
return fmt.Sprintf("已调用工具:%s(%s)。", displayName, argSummary)
|
||||
}
|
||||
|
||||
func buildToolArgumentsPreviewCN(args map[string]any) string {
|
||||
if len(args) <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
type argPair struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
orderedPairs := []argPair{
|
||||
{Key: "title", Label: "任务标题"},
|
||||
{Key: "task_name", Label: "任务名称"},
|
||||
{Key: "deadline_at", Label: "截止时间"},
|
||||
{Key: "new_day", Label: "目标日期"},
|
||||
{Key: "new_slot_start", Label: "目标开始时段"},
|
||||
{Key: "day", Label: "日期"},
|
||||
{Key: "day_start", Label: "开始日"},
|
||||
{Key: "day_end", Label: "结束日"},
|
||||
{Key: "day_scope", Label: "日期范围"},
|
||||
{Key: "day_of_week", Label: "星期"},
|
||||
{Key: "week", Label: "周"},
|
||||
{Key: "week_from", Label: "起始周"},
|
||||
{Key: "week_to", Label: "结束周"},
|
||||
{Key: "week_filter", Label: "周筛选"},
|
||||
{Key: "slot_start", Label: "开始时段"},
|
||||
{Key: "slot_end", Label: "结束时段"},
|
||||
{Key: "slot_type", Label: "时段类型"},
|
||||
{Key: "slot_types", Label: "时段类型"},
|
||||
{Key: "task_id", Label: "任务 ID"},
|
||||
{Key: "task_ids", Label: "任务 ID 列表"},
|
||||
{Key: "task_item_id", Label: "任务项 ID"},
|
||||
{Key: "task_item_ids", Label: "任务项 ID 列表"},
|
||||
{Key: "query", Label: "查询词"},
|
||||
{Key: "keyword", Label: "关键词"},
|
||||
{Key: "domain", Label: "工具域"},
|
||||
{Key: "mode", Label: "激活模式"},
|
||||
{Key: "all", Label: "移除全部"},
|
||||
{Key: "top_k", Label: "返回数量"},
|
||||
{Key: "url", Label: "链接"},
|
||||
{Key: "reason", Label: "原因"},
|
||||
{Key: "limit", Label: "数量"},
|
||||
}
|
||||
|
||||
items := make([]string, 0, 2)
|
||||
for _, pair := range orderedPairs {
|
||||
rawValue, exists := args[pair.Key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
valueText := formatToolArgValueByKeyCN(pair.Key, rawValue)
|
||||
if valueText == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, fmt.Sprintf("%s:%s", pair.Label, valueText))
|
||||
if len(items) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(items, ",")
|
||||
}
|
||||
|
||||
func resolveToolDisplayNameCN(toolName string) string {
|
||||
name := strings.TrimSpace(toolName)
|
||||
if name == "" {
|
||||
return "未知工具"
|
||||
}
|
||||
|
||||
displayNameMap := map[string]string{
|
||||
"get_overview": "查看总览",
|
||||
"query_range": "查询时间范围",
|
||||
"queue_status": "查看任务队列",
|
||||
"queue_pop_head": "获取队首任务",
|
||||
"queue_apply_head_move": "应用队首任务时段",
|
||||
"queue_skip_head": "跳过队首任务",
|
||||
"query_target_tasks": "查询目标任务",
|
||||
"query_available_slots": "查询可用时段",
|
||||
"get_task_info": "查看任务信息",
|
||||
"analyze_health": "综合体检",
|
||||
"analyze_rhythm": "分析学习节律",
|
||||
"web_search": "网页搜索",
|
||||
"web_fetch": "网页抓取",
|
||||
"move": "移动任务",
|
||||
"place": "放置任务",
|
||||
"swap": "交换任务",
|
||||
"batch_move": "批量移动任务",
|
||||
"unplace": "移出任务安排",
|
||||
"upsert_task_class": "写入任务类",
|
||||
"context_tools_add": "激活工具域",
|
||||
"context_tools_remove": "移除工具域",
|
||||
}
|
||||
|
||||
if label, ok := displayNameMap[name]; ok {
|
||||
return label
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func formatToolArgValueByKeyCN(key string, value any) string {
|
||||
switch key {
|
||||
case "day_scope":
|
||||
scope := strings.ToLower(strings.TrimSpace(formatToolArgValueCN(value)))
|
||||
switch scope {
|
||||
case "workday":
|
||||
return "工作日"
|
||||
case "weekend":
|
||||
return "周末"
|
||||
case "all":
|
||||
return "全部日期"
|
||||
default:
|
||||
return scope
|
||||
}
|
||||
case "day_of_week":
|
||||
weekdays := parseAnyToIntSlice(value)
|
||||
if len(weekdays) <= 0 {
|
||||
return formatToolArgValueCN(value)
|
||||
}
|
||||
labels := make([]string, 0, len(weekdays))
|
||||
for _, day := range weekdays {
|
||||
labels = append(labels, fmt.Sprintf("周%d", day))
|
||||
if len(labels) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(labels, "、")
|
||||
case "task_ids", "task_item_ids", "week_filter":
|
||||
values := parseAnyToIntSlice(value)
|
||||
if len(values) <= 0 {
|
||||
return formatToolArgValueCN(value)
|
||||
}
|
||||
items := make([]string, 0, len(values))
|
||||
for _, current := range values {
|
||||
items = append(items, strconv.Itoa(current))
|
||||
if len(items) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(items, "、")
|
||||
case "url":
|
||||
return truncateToolSummaryCN(formatToolArgValueCN(value))
|
||||
case "reason", "title", "task_name", "query", "keyword":
|
||||
return truncateToolSummaryCN(formatToolArgValueCN(value))
|
||||
default:
|
||||
return formatToolArgValueCN(value)
|
||||
}
|
||||
}
|
||||
|
||||
func formatToolArgValueCN(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int8:
|
||||
return strconv.Itoa(int(v))
|
||||
case int16:
|
||||
return strconv.Itoa(int(v))
|
||||
case int32:
|
||||
return strconv.Itoa(int(v))
|
||||
case int64:
|
||||
return strconv.Itoa(int(v))
|
||||
case float32:
|
||||
return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
|
||||
case float64:
|
||||
return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
|
||||
case bool:
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
case []any:
|
||||
values := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
text := formatToolArgValueCN(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, text)
|
||||
if len(values) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(values, "、")
|
||||
default:
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" || text == "map[]" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
182
backend/services/agent/node/interrupt.go
Normal file
182
backend/services/agent/node/interrupt.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
)
|
||||
|
||||
const (
|
||||
interruptStageName = "interrupt"
|
||||
interruptSpeakBlockID = "interrupt.speak"
|
||||
interruptStatusBlockID = "interrupt.status"
|
||||
)
|
||||
|
||||
// InterruptNodeInput 描述中断节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 不需要 LLM Client — 所有文本已在 PendingInteraction.DisplayText 里;
|
||||
// 2. RuntimeState 提供 PendingInteraction;
|
||||
// 3. ChunkEmitter 负责推送收尾消息。
|
||||
type InterruptNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunInterruptNode 执行一轮中断节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. ask_user → 把 DisplayText 当普通 assistant 消息伪流式输出,说完就停;
|
||||
// 2. confirm → 确认卡片已由 confirm 节点推送,无需额外输出;
|
||||
// 3. 状态持久化已由 agent_nodes 层统一处理,Interrupt 不再需要自行存快照;
|
||||
// 4. 节点结束后 graph 走 END,当前连接断开。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 中断就是正常对话的结束 — 助手说了问题/确认卡片,然后停下来等用户回复;
|
||||
// 2. 用户下次回复时走正常 chat 入口,chat 节点负责 resume;
|
||||
// 3. 不做特殊 UI,不需要前端适配新的交互模式。
|
||||
func RunInterruptNode(ctx context.Context, input InterruptNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareInterruptNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pending := runtimeState.PendingInteraction
|
||||
if pending == nil {
|
||||
// 无 pending interaction → 不应到达此处,防御性返回。
|
||||
return fmt.Errorf("interrupt node: 无待处理交互")
|
||||
}
|
||||
|
||||
switch pending.Type {
|
||||
case agentmodel.PendingInteractionTypeAskUser:
|
||||
return handleInterruptAskUser(ctx, runtimeState, input.PersistVisibleMessage, pending, conversationContext, emitter)
|
||||
case agentmodel.PendingInteractionTypeConfirm:
|
||||
return handleInterruptConfirm(pending, emitter)
|
||||
default:
|
||||
// connection_lost 等其他类型 → 仅持久化,不输出。
|
||||
return handleInterruptDefault(pending, emitter)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInterruptAskUser 处理追问型中断。
|
||||
//
|
||||
// 把 PendingInteraction.DisplayText 当普通 assistant 消息伪流式输出,
|
||||
// 写入历史,然后结束。用户体验和正常对话一样 — 助手问了问题,停下来等回复。
|
||||
func handleInterruptAskUser(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
persist agentmodel.PersistVisibleMessageFunc,
|
||||
pending *agentmodel.PendingInteraction,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
text := pending.DisplayText
|
||||
if text == "" {
|
||||
text = "请补充更多信息。"
|
||||
}
|
||||
|
||||
speakStreamed := readPendingMetadataBool(pending, agentmodel.PendingMetaAskUserSpeakStreamed)
|
||||
historyAppended := readPendingMetadataBool(pending, agentmodel.PendingMetaAskUserHistoryAppended)
|
||||
|
||||
// 1. 若上游节点已流式推送过 ask_user 文本,则这里跳过二次正文推送;
|
||||
// 2. 这样既保留 interrupt 的统一收口状态,又避免前端出现重复气泡。
|
||||
if !speakStreamed {
|
||||
// 伪流式输出,和 chatReply 一样的体感。
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx, interruptSpeakBlockID, interruptStageName,
|
||||
text,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("追问消息推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入对话历史,下一轮 resume 时 LLM 能看到这个上下文。
|
||||
msg := schema.AssistantMessage(text, nil)
|
||||
if !historyAppended {
|
||||
conversationContext.AppendHistory(msg)
|
||||
}
|
||||
persistVisibleAssistantMessage(ctx, persist, runtimeState.EnsureCommonState(), msg)
|
||||
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
interruptStatusBlockID, interruptStageName,
|
||||
"ask_user", "已追问用户,等待回复。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPendingMetadataBool(pending *agentmodel.PendingInteraction, key string) bool {
|
||||
if pending == nil || pending.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
raw, exists := pending.Metadata[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// handleInterruptConfirm 处理确认型中断。
|
||||
//
|
||||
// 确认卡片已由 confirm 节点推送,这里只需推送状态通知并持久化。
|
||||
func handleInterruptConfirm(
|
||||
pending *agentmodel.PendingInteraction,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
interruptStatusBlockID, interruptStageName,
|
||||
"confirm", "等待用户确认。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleInterruptDefault 处理其他类型的中断(如 connection_lost)。
|
||||
func handleInterruptDefault(
|
||||
pending *agentmodel.PendingInteraction,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
interruptStatusBlockID, interruptStageName,
|
||||
"interrupted", "会话已中断。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareInterruptNodeInput 校验并准备中断节点的运行态依赖。
|
||||
func prepareInterruptNodeInput(input InterruptNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("interrupt node: runtime state 不能为空")
|
||||
}
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
121
backend/services/agent/node/llm_debug.go
Normal file
121
backend/services/agent/node/llm_debug.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// logNodeLLMContext 将某个节点即将送入 LLM 的完整消息上下文按统一格式打印到日志。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 统一输出 stage / phase / chat / round,方便按一次请求内的多次 LLM 调用串联排查;
|
||||
// 2. 完整展开 messages,不做截断,保证问题复现时能直接对照 prompt 组装结果;
|
||||
// 3. 该函数只负责调试日志,不参与任何业务判断,也不修改上下文内容。
|
||||
func logNodeLLMContext(
|
||||
stage string,
|
||||
phase string,
|
||||
flowState *agentmodel.CommonState,
|
||||
messages []*schema.Message,
|
||||
) {
|
||||
chatID := ""
|
||||
roundUsed := 0
|
||||
if flowState != nil {
|
||||
chatID = flowState.ConversationID
|
||||
roundUsed = flowState.RoundUsed
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] %s LLM context begin phase=%s chat=%s round=%d message_count=%d\n%s\n[DEBUG] %s LLM context end phase=%s chat=%s round=%d",
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
len(messages),
|
||||
formatLLMMessagesForDebug(messages),
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
)
|
||||
}
|
||||
|
||||
// formatLLMMessagesForDebug 将本轮送入 LLM 的完整消息上下文展开成可读多行日志。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 按消息索引逐条输出,便于和上游上下文构造步骤逐项对齐;
|
||||
// 2. 完整输出 content / reasoning_content / tool_calls / extra,不做截断;
|
||||
// 3. 仅用于调试打点,不参与业务决策。
|
||||
func formatLLMMessagesForDebug(messages []*schema.Message) string {
|
||||
if len(messages) == 0 {
|
||||
return "(empty messages)"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, msg := range messages {
|
||||
sb.WriteString(fmt.Sprintf("----- message[%d] -----\n", i))
|
||||
if msg == nil {
|
||||
sb.WriteString("role: <nil>\n\n")
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("role: %s\n", msg.Role))
|
||||
|
||||
if strings.TrimSpace(msg.ToolCallID) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_call_id: %s\n", msg.ToolCallID))
|
||||
}
|
||||
if strings.TrimSpace(msg.ToolName) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_name: %s\n", msg.ToolName))
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
sb.WriteString("tool_calls:\n")
|
||||
for j, call := range msg.ToolCalls {
|
||||
sb.WriteString(fmt.Sprintf(" - [%d] id=%s type=%s function=%s\n", j, call.ID, call.Type, call.Function.Name))
|
||||
sb.WriteString(" arguments:\n")
|
||||
sb.WriteString(indentMultilineForDebug(call.Function.Arguments, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
sb.WriteString("reasoning_content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.ReasoningContent, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.Content, " "))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(msg.Extra) > 0 {
|
||||
sb.WriteString("extra:\n")
|
||||
raw, err := json.MarshalIndent(msg.Extra, "", " ")
|
||||
if err != nil {
|
||||
sb.WriteString(indentMultilineForDebug("<marshal_error>", " "))
|
||||
} else {
|
||||
sb.WriteString(indentMultilineForDebug(string(raw), " "))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// indentMultilineForDebug 为多行文本统一添加前缀缩进,避免日志折行后难以阅读。
|
||||
func indentMultilineForDebug(text, prefix string) string {
|
||||
if text == "" {
|
||||
return prefix + "<empty>"
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := range lines {
|
||||
lines[i] = prefix + lines[i]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
398
backend/services/agent/node/plan.go
Normal file
398
backend/services/agent/node/plan.go
Normal file
@@ -0,0 +1,398 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
planStageName = "plan"
|
||||
planStatusBlockID = "plan.status"
|
||||
planSpeakBlockID = "plan.speak"
|
||||
planSummaryBlockID = "plan.summary"
|
||||
planPinnedKey = "current_plan"
|
||||
planCurrentStepKey = "current_step"
|
||||
planCurrentStepTitle = "当前步骤"
|
||||
planFullPlanTitle = "当前完整计划"
|
||||
)
|
||||
|
||||
// PlanNodeInput 描述单轮规划节点执行所需的最小依赖。
|
||||
type PlanNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
ResumeNode string
|
||||
AlwaysExecute bool // true 时计划生成后自动确认,不进入 confirm 节点
|
||||
ThinkingEnabled bool // 是否开启 thinking,由 config.yaml 的 agent.thinking.plan 注入
|
||||
CompactionStore agentmodel.CompactionStore // 上下文压缩持久化
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunPlanNode 执行一轮规划节点逻辑。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先校验最小依赖,并推送一条"正在规划"的状态,避免用户空等;
|
||||
// 2. 构造本轮规划输入,调用 LLM Stream 接口;
|
||||
// 3. 从流中提取 <SMARTFLOW_DECISION> 标签内的 JSON 决策,同时流式推送 speak 正文;
|
||||
// 4. 按 action 推进流程:
|
||||
// 4.1 continue:继续停留在 planning;
|
||||
// 4.2 ask_user:打开 pending interaction,后续交给 interrupt 收口;
|
||||
// 4.3 plan_done:固化完整计划,刷新 pinned context,并进入 waiting_confirm。
|
||||
func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := preparePlanNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
// 1. 先发一条阶段状态,让前端知道当前已经进入规划环节。
|
||||
if err := emitter.EmitStatus(
|
||||
planStatusBlockID,
|
||||
planStageName,
|
||||
"planning",
|
||||
"正在梳理目标并补全执行计划。",
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("规划阶段状态推送失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 构造本轮规划输入。
|
||||
messages := agentprompt.BuildPlanMessages(flowState, conversationContext, input.UserInput)
|
||||
messages = compactUnifiedMessagesIfNeeded(ctx, messages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: planStageName,
|
||||
StatusBlockID: planStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(planStageName, "planning", flowState, messages)
|
||||
|
||||
// 3. 两阶段流式规划:从 LLM 流中先提取 <SMARTFLOW_DECISION> 决策标签,再流式推送 speak 正文。
|
||||
reader, err := input.Client.Stream(
|
||||
ctx,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 0.2,
|
||||
// 显式设置上限,避免依赖框架默认值(默认 4096)导致长决策被截断。
|
||||
// 注意:当前模型接口 max_tokens 上限为 131072,超过会 400。
|
||||
MaxTokens: 131072,
|
||||
Thinking: resolveThinkingMode(input.ThinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": planStageName,
|
||||
"phase": "planning",
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("规划阶段 Stream 调用失败: %w", err)
|
||||
}
|
||||
|
||||
parser := agentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
speakStreamed := false
|
||||
reasoningDigestor, digestorErr := emitter.NewReasoningDigestor(ctx, planSpeakBlockID, planStageName)
|
||||
if digestorErr != nil {
|
||||
return fmt.Errorf("规划 thinking 摘要器初始化失败: %w", digestorErr)
|
||||
}
|
||||
defer func() {
|
||||
if reasoningDigestor != nil {
|
||||
_ = reasoningDigestor.Close(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
// 3.1 阶段一:解析决策标签。
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] plan stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
// thinking 内容只进入摘要器,不再把 raw reasoning_content 透传给前端。
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
if result.Fallback || result.ParseFailed {
|
||||
return fmt.Errorf("规划解析失败,原始输出=%s", result.RawBuffer)
|
||||
}
|
||||
|
||||
decision, parseErr := llmservice.ParseJSONObject[agentmodel.PlanDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("规划决策 JSON 解析失败: %w (raw=%s)", parseErr, result.RawBuffer)
|
||||
}
|
||||
if validateErr := decision.Validate(); validateErr != nil {
|
||||
return fmt.Errorf("规划决策不合法: %w", validateErr)
|
||||
}
|
||||
|
||||
// 3.2 阶段二:流式推送 speak(同一 reader 继续读取)。
|
||||
var fullText strings.Builder
|
||||
if visible != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] plan speak stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(chunk2.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk2.ReasoningContent)
|
||||
}
|
||||
}
|
||||
if chunk2.Content != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
}
|
||||
decision.Speak = fullText.String()
|
||||
|
||||
// 4. 若有 speak 且不是 ask_user(ask_user 交给 interrupt 收口),写入历史。
|
||||
if strings.TrimSpace(decision.Speak) != "" && decision.Action != agentmodel.PlanActionAskUser {
|
||||
msg := schema.AssistantMessage(decision.Speak, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
// 5. 按规划动作推进流程状态。
|
||||
return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision, speakStreamed)
|
||||
}
|
||||
|
||||
// 流结束但未找到决策标签。
|
||||
return fmt.Errorf("规划阶段流结束但未提取到决策标签")
|
||||
}
|
||||
|
||||
// handlePlanAction 根据 PlanDecision.Action 推进流程状态。
|
||||
func handlePlanAction(
|
||||
ctx context.Context,
|
||||
input PlanNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
decision *agentmodel.PlanDecision,
|
||||
askUserSpeakStreamed bool,
|
||||
) error {
|
||||
switch decision.Action {
|
||||
case agentmodel.PlanActionContinue:
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
case agentmodel.PlanActionAskUser:
|
||||
question := resolvePlanAskUserText(decision)
|
||||
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
|
||||
// 1. plan 阶段若已流式推送过 ask_user 文本,interrupt 侧应避免重复正文输出;
|
||||
// 2. plan 阶段 ask_user 不会提前写入 history,这里显式标记为 false。
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserSpeakStreamed, askUserSpeakStreamed)
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserHistoryAppended, false)
|
||||
return nil
|
||||
case agentmodel.PlanActionDone:
|
||||
flowState.FinishPlan(decision.PlanSteps)
|
||||
flowState.PendingContextHook = clonePlanContextHook(decision.ContextHook)
|
||||
writePlanPinnedBlocks(conversationContext, decision.PlanSteps)
|
||||
if decision.NeedsRoughBuild {
|
||||
flowState.NeedsRoughBuild = true
|
||||
if len(decision.TaskClassIDs) > 0 {
|
||||
flowState.TaskClassIDs = decision.TaskClassIDs
|
||||
}
|
||||
}
|
||||
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
|
||||
if input.AlwaysExecute {
|
||||
summary := strings.TrimSpace(buildPlanSummary(decision.PlanSteps))
|
||||
if summary != "" {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
planSummaryBlockID,
|
||||
planStageName,
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动执行前计划摘要推送失败: %w", err)
|
||||
}
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
flowState.ConfirmPlan()
|
||||
_ = emitter.EmitStatus(
|
||||
planStatusBlockID,
|
||||
planStageName,
|
||||
"plan_auto_confirmed",
|
||||
"计划已自动确认,开始执行。",
|
||||
false,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
llmOutput := decision.Speak
|
||||
if strings.TrimSpace(llmOutput) == "" {
|
||||
llmOutput = decision.Reason
|
||||
}
|
||||
AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
llmOutput,
|
||||
fmt.Sprintf("你输出的 action \"%s\" 不是合法的执行动作。", decision.Action),
|
||||
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、next_plan(推进到下一步)、done(任务完成)。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func preparePlanNodeInput(input PlanNodeInput) (*agentmodel.AgentRuntimeState, *agentmodel.ConversationContext, *agentstream.ChunkEmitter, error) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("plan node: runtime state 不能为空")
|
||||
}
|
||||
if input.Client == nil {
|
||||
return nil, nil, nil, fmt.Errorf("plan node: plan client 未注入")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix())
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
|
||||
func resolvePlanAskUserText(decision *agentmodel.PlanDecision) string {
|
||||
if decision == nil {
|
||||
return "我还缺一点关键信息,想先向你确认一下。"
|
||||
}
|
||||
if strings.TrimSpace(decision.Speak) != "" {
|
||||
return strings.TrimSpace(decision.Speak)
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) != "" {
|
||||
return strings.TrimSpace(decision.Reason)
|
||||
}
|
||||
return "我还缺一点关键信息,想先向你确认一下。"
|
||||
}
|
||||
|
||||
func clonePlanContextHook(hook *agentmodel.ContextHook) *agentmodel.ContextHook {
|
||||
if hook == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *hook
|
||||
if len(hook.Packs) > 0 {
|
||||
cloned.Packs = append([]string(nil), hook.Packs...)
|
||||
}
|
||||
cloned.Normalize()
|
||||
if cloned.Domain == "" {
|
||||
return nil
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func writePlanPinnedBlocks(ctx *agentmodel.ConversationContext, steps []agentmodel.PlanStep) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fullPlanText := buildPinnedPlanText(steps)
|
||||
if strings.TrimSpace(fullPlanText) != "" {
|
||||
ctx.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: planPinnedKey,
|
||||
Title: planFullPlanTitle,
|
||||
Content: fullPlanText,
|
||||
})
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
firstStep := strings.TrimSpace(steps[0].Content)
|
||||
if strings.TrimSpace(steps[0].DoneWhen) != "" {
|
||||
firstStep = fmt.Sprintf("%s\n完成判定:%s", firstStep, strings.TrimSpace(steps[0].DoneWhen))
|
||||
}
|
||||
ctx.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: planCurrentStepKey,
|
||||
Title: planCurrentStepTitle,
|
||||
Content: firstStep,
|
||||
})
|
||||
}
|
||||
|
||||
func buildPinnedPlanText(steps []agentmodel.PlanStep) string {
|
||||
if len(steps) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(steps))
|
||||
for i, step := range steps {
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%d. %s", i+1, content)
|
||||
if strings.TrimSpace(step.DoneWhen) != "" {
|
||||
line += fmt.Sprintf("\n完成判定:%s", strings.TrimSpace(step.DoneWhen))
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n\n"))
|
||||
}
|
||||
|
||||
// resolveThinkingMode 根据配置布尔值返回对应的 ThinkingMode。
|
||||
// 供 plan / execute / deliver 节点统一使用。
|
||||
func resolveThinkingMode(enabled bool) llmservice.ThinkingMode {
|
||||
if enabled {
|
||||
return llmservice.ThinkingModeEnabled
|
||||
}
|
||||
return llmservice.ThinkingModeDisabled
|
||||
}
|
||||
584
backend/services/agent/node/quick_task.go
Normal file
584
backend/services/agent/node/quick_task.go
Normal file
@@ -0,0 +1,584 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
taskmodel "github.com/LoveLosita/smartflow/backend/model"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
quickTaskStageName = "quick_task"
|
||||
quickTaskBlockID = "qt_main"
|
||||
quickTaskResultCardID = "quick_task.result"
|
||||
taskRecordSourceQuickNote = "quick_note"
|
||||
)
|
||||
|
||||
// QuickTaskNodeInput 描述快捷任务节点的输入。
|
||||
type QuickTaskNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
QuickTaskDeps agentmodel.QuickTaskDeps
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// quickTaskDecision 是从 LLM 输出中解析的结构化意图。
|
||||
type quickTaskDecision struct {
|
||||
Action string `json:"action"`
|
||||
Title string `json:"title,omitempty"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
PriorityGroup *int `json:"priority_group,omitempty"`
|
||||
EstimatedSections *int `json:"estimated_sections,omitempty"`
|
||||
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"`
|
||||
TaskID *int `json:"task_id,omitempty"`
|
||||
|
||||
// query 参数
|
||||
Quadrant *int `json:"quadrant,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
DeadlineAfter string `json:"deadline_after,omitempty"`
|
||||
DeadlineBefore string `json:"deadline_before,omitempty"`
|
||||
|
||||
// ask 参数
|
||||
Question string `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// quickTaskActionResult 是 quick_task 执行动作后的统一回包。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. AssistantText 是本轮要补发给用户的短正文;
|
||||
// 2. BusinessCard 仅在“业务真实成功”时携带,失败/追问场景必须为空;
|
||||
// 3. 不负责直接发射,发射时机由 RunQuickTaskNode 统一控制。
|
||||
type quickTaskActionResult struct {
|
||||
AssistantText string
|
||||
BusinessCard *agentstream.StreamBusinessCardExtra
|
||||
}
|
||||
|
||||
// RunQuickTaskNode 执行快捷任务节点:流式 LLM 提取意图 → 直接调 service → 追加结果。
|
||||
func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error {
|
||||
flowState := input.RuntimeState.EnsureCommonState()
|
||||
emitter := input.ChunkEmitter
|
||||
|
||||
// 1. 构造 messages。
|
||||
messages := agentprompt.BuildQuickTaskMessagesSimple(input.UserInput)
|
||||
|
||||
// 2. 真流式调用 LLM。
|
||||
reader, err := input.Client.Stream(ctx, messages, llmservice.GenerateOptions{
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 512,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] quick_task: Stream 调用失败 chat=%s err=%v", flowState.ConversationID, err)
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, "抱歉,处理任务时出了点问题,请重试。", true)
|
||||
flowState.Phase = agentmodel.PhaseDone
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 两阶段流式解析。
|
||||
parser := agentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
var decision *quickTaskDecision
|
||||
var fullText strings.Builder
|
||||
|
||||
// 阶段一:解析决策标签。
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] quick_task stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
|
||||
// Fallback / 解析失败:把原始文本当作纯回复推送。
|
||||
if result.Fallback || result.ParseFailed {
|
||||
log.Printf("[DEBUG] quick_task: 标签解析失败 chat=%s raw=%s", flowState.ConversationID, result.RawBuffer)
|
||||
if result.RawBuffer != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.RawBuffer, firstChunk)
|
||||
fullText.WriteString(result.RawBuffer)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 解析 JSON。
|
||||
log.Printf("[DEBUG] quick_task: LLM 原始决策 JSON chat=%s json=%s", flowState.ConversationID, result.DecisionJSON)
|
||||
var parseErr error
|
||||
decision, parseErr = llmservice.ParseJSONObject[quickTaskDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
log.Printf("[DEBUG] quick_task: JSON 解析失败 chat=%s json=%s", flowState.ConversationID, result.DecisionJSON)
|
||||
if result.RawBuffer != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.RawBuffer, firstChunk)
|
||||
fullText.WriteString(result.RawBuffer)
|
||||
}
|
||||
break
|
||||
}
|
||||
log.Printf("[DEBUG] quick_task: 解析结果 chat=%s action=%s title=%s deadline_at=%s priority_group=%v estimated_sections=%v urgency_threshold_at=%q",
|
||||
flowState.ConversationID, decision.Action, decision.Title, decision.DeadlineAt, decision.PriorityGroup, decision.EstimatedSections, decision.UrgencyThresholdAt)
|
||||
|
||||
// 阶段二:流式推送标签后正文。
|
||||
if visible != "" {
|
||||
if emitErr := emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, visible, firstChunk); emitErr != nil {
|
||||
log.Printf("[WARN] quick_task emit error chat=%s err=%v", flowState.ConversationID, emitErr)
|
||||
}
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] quick_task stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil || chunk2.Content == "" {
|
||||
continue
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
log.Printf("[WARN] quick_task emit error chat=%s err=%v", flowState.ConversationID, emitErr)
|
||||
}
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 4. 流结束但未解析到决策 → 降级为纯文本回复。
|
||||
if decision == nil {
|
||||
finalText := fullText.String()
|
||||
if strings.TrimSpace(finalText) == "" {
|
||||
finalText = "抱歉,处理任务时出了点问题,请重试。"
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, finalText, true)
|
||||
}
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
flowState.Phase = agentmodel.PhaseDone
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] quick_task: chat=%s action=%s raw_title=%s", flowState.ConversationID, decision.Action, decision.Title)
|
||||
|
||||
// 5. 根据意图执行操作。
|
||||
result := quickTaskActionResult{}
|
||||
switch decision.Action {
|
||||
case "create":
|
||||
result = handleQuickTaskCreate(ctx, input, decision, flowState)
|
||||
case "query":
|
||||
result = handleQuickTaskQuery(ctx, input, decision, flowState)
|
||||
case "ask":
|
||||
result.AssistantText = decision.Question
|
||||
if result.AssistantText == "" {
|
||||
result.AssistantText = "你想记录什么呢?告诉我具体内容吧。"
|
||||
}
|
||||
default:
|
||||
result.AssistantText = "抱歉,我没有理解你的意思。你可以试试说「记一下明天开会」或「看看我的任务」。"
|
||||
}
|
||||
|
||||
// 6. 追加操作结果正文。
|
||||
if result.AssistantText != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.AssistantText, false)
|
||||
fullText.WriteString(result.AssistantText)
|
||||
}
|
||||
|
||||
messagePersisted := false
|
||||
// 7.1 有业务卡片时,先落正文,再发卡片,保证 timeline 顺序与前端展示一致。
|
||||
// 1. 先持久化正文,确保 timeline 里的 assistant_text seq 一定早于 business_card;
|
||||
// 2. 再发 business_card,保证“短正文 + 紧跟卡片”的时序契约;
|
||||
// 3. 卡片发射失败只记日志,不回滚正文,避免用户侧出现“看不到结果文本”的回退。
|
||||
if result.BusinessCard != nil {
|
||||
finalText := fullText.String()
|
||||
if strings.TrimSpace(finalText) != "" {
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
messagePersisted = true
|
||||
}
|
||||
if emitErr := emitter.EmitBusinessCard(quickTaskResultCardID, quickTaskStageName, result.BusinessCard); emitErr != nil {
|
||||
log.Printf("[WARN] quick_task emit business_card error chat=%s err=%v", flowState.ConversationID, emitErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 7.2 非卡片路径沿用原有收口:本轮正文统一一次性写入 history。
|
||||
if !messagePersisted {
|
||||
finalText := fullText.String()
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseDone
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleQuickTaskCreate 处理任务创建。
|
||||
func handleQuickTaskCreate(
|
||||
ctx context.Context,
|
||||
input QuickTaskNodeInput,
|
||||
decision *quickTaskDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) quickTaskActionResult {
|
||||
_ = ctx
|
||||
title := strings.TrimSpace(decision.Title)
|
||||
if title == "" {
|
||||
return quickTaskActionResult{AssistantText: "你想记录什么呢?告诉我具体内容吧。"}
|
||||
}
|
||||
|
||||
var deadline *time.Time
|
||||
if raw := strings.TrimSpace(decision.DeadlineAt); raw != "" {
|
||||
parsed, err := agentshared.ParseOptionalDeadline(raw)
|
||||
if err != nil {
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("截止时间格式不太对(%s),不过我先把任务记下来啦。", err)}
|
||||
}
|
||||
deadline = parsed
|
||||
}
|
||||
|
||||
priorityGroup := 0
|
||||
if decision.PriorityGroup != nil && agentshared.IsValidTaskPriority(*decision.PriorityGroup) {
|
||||
priorityGroup = *decision.PriorityGroup
|
||||
}
|
||||
if priorityGroup == 0 {
|
||||
priorityGroup = quickNoteFallbackPriority(deadline)
|
||||
}
|
||||
estimatedSections := taskmodel.NormalizeEstimatedSections(decision.EstimatedSections)
|
||||
|
||||
var urgencyThreshold *time.Time
|
||||
if raw := strings.TrimSpace(decision.UrgencyThresholdAt); raw != "" {
|
||||
parsed, err := agentshared.ParseOptionalDeadline(raw)
|
||||
if err == nil {
|
||||
urgencyThreshold = parsed
|
||||
}
|
||||
}
|
||||
// LLM 经常省略 urgency_threshold_at,代码兜底:priorityGroup=2 且有 deadline 时自动推算。
|
||||
if urgencyThreshold == nil && priorityGroup == 2 && deadline != nil {
|
||||
fallback := deadline.Add(-24 * time.Hour)
|
||||
urgencyThreshold = &fallback
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] quick_task: CreateTask 参数 chat=%s title=%s priorityGroup=%d estimatedSections=%d deadline=%v urgencyThreshold=%v urgency_raw=%q estimated_raw=%v",
|
||||
flowState.ConversationID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold, decision.UrgencyThresholdAt, decision.EstimatedSections)
|
||||
taskID, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold)
|
||||
if err != nil {
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("记录失败了(%s),稍后再试试?", err)}
|
||||
}
|
||||
|
||||
flowState.UsedQuickNote = true
|
||||
return quickTaskActionResult{
|
||||
AssistantText: "已帮你记下这条任务。",
|
||||
BusinessCard: buildTaskRecordBusinessCard(taskID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold),
|
||||
}
|
||||
}
|
||||
|
||||
// handleQuickTaskQuery 处理任务查询。
|
||||
func handleQuickTaskQuery(
|
||||
ctx context.Context,
|
||||
input QuickTaskNodeInput,
|
||||
decision *quickTaskDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) quickTaskActionResult {
|
||||
params := agentmodel.TaskQueryParams{
|
||||
SortBy: "deadline",
|
||||
Order: "asc",
|
||||
Limit: 5,
|
||||
IncludeCompleted: false,
|
||||
}
|
||||
|
||||
if decision.Quadrant != nil && *decision.Quadrant >= 1 && *decision.Quadrant <= 4 {
|
||||
params.Quadrant = decision.Quadrant
|
||||
}
|
||||
if kw := strings.TrimSpace(decision.Keyword); kw != "" {
|
||||
params.Keyword = kw
|
||||
}
|
||||
if decision.Limit != nil && *decision.Limit > 0 && *decision.Limit <= 20 {
|
||||
params.Limit = *decision.Limit
|
||||
}
|
||||
params.DeadlineAfter = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineAfter, "deadline_after", flowState)
|
||||
params.DeadlineBefore = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineBefore, "deadline_before", flowState)
|
||||
// 1. 若模型给出了颠倒的时间窗(before<=after),当前轮降级为“不加时间窗”继续查询;
|
||||
// 2. 这样能避免误筛选成空结果,同时把异常留给日志排查;
|
||||
// 3. 这里只做兜底,不尝试替模型自动纠正语义,避免引入额外猜测。
|
||||
if params.DeadlineAfter != nil && params.DeadlineBefore != nil && !params.DeadlineBefore.After(*params.DeadlineAfter) {
|
||||
log.Printf("[WARN] quick_task: query 时间窗无效 chat=%s after=%s before=%s,已降级为无时间窗筛选",
|
||||
flowState.ConversationID,
|
||||
formatQuickTaskTime(params.DeadlineAfter),
|
||||
formatQuickTaskTime(params.DeadlineBefore),
|
||||
)
|
||||
params.DeadlineAfter = nil
|
||||
params.DeadlineBefore = nil
|
||||
}
|
||||
|
||||
results, err := input.QuickTaskDeps.QueryTasks(ctx, flowState.UserID, params)
|
||||
if err != nil {
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("查询失败了(%s),稍后再试试?", err)}
|
||||
}
|
||||
|
||||
card := buildTaskQueryBusinessCard(params, results)
|
||||
if len(results) == 0 {
|
||||
return quickTaskActionResult{
|
||||
AssistantText: "我这边没查到匹配任务。",
|
||||
BusinessCard: card,
|
||||
}
|
||||
}
|
||||
|
||||
return quickTaskActionResult{
|
||||
AssistantText: fmt.Sprintf("我找到 %d 条任务,整理成卡片给你。", len(results)),
|
||||
BusinessCard: card,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTaskRecordBusinessCard(taskID int, title string, priorityGroup int, estimatedSections int, deadline *time.Time, urgencyThreshold *time.Time) *agentstream.StreamBusinessCardExtra {
|
||||
data := map[string]any{
|
||||
"id": taskID,
|
||||
"title": strings.TrimSpace(title),
|
||||
"priority_group": priorityGroup,
|
||||
"estimated_sections": estimatedSections,
|
||||
"priority_label": agentshared.PriorityLabelCN(priorityGroup),
|
||||
"status": "todo",
|
||||
}
|
||||
if formatted := formatQuickTaskTime(deadline); formatted != "" {
|
||||
data["deadline_at"] = formatted
|
||||
}
|
||||
if formatted := formatQuickTaskTime(urgencyThreshold); formatted != "" {
|
||||
data["urgency_threshold_at"] = formatted
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// 1. quick_task 当前只有 action=create,未显式区分“随口记 / 正式创建任务”;
|
||||
// 2. 仅凭当前 prompt 决策无法稳定判断 source=create_task,会引入误判;
|
||||
// 3. 本轮按最小安全口径固定为 quick_note,等后续补稳定判别字段再切分。
|
||||
return &agentstream.StreamBusinessCardExtra{
|
||||
CardType: "task_record",
|
||||
Title: "已帮你记下",
|
||||
Summary: "一条轻量提醒已写入任务系统",
|
||||
Source: taskRecordSourceQuickNote,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTaskQueryBusinessCard(params agentmodel.TaskQueryParams, results []agentmodel.TaskQueryResult) *agentstream.StreamBusinessCardExtra {
|
||||
taskItems := make([]map[string]any, 0, len(results))
|
||||
for _, task := range results {
|
||||
item := map[string]any{
|
||||
"id": task.ID,
|
||||
"title": strings.TrimSpace(task.Title),
|
||||
"priority_group": task.PriorityGroup,
|
||||
"estimated_sections": task.EstimatedSections,
|
||||
"priority_label": agentshared.PriorityLabelCN(task.PriorityGroup),
|
||||
"is_completed": task.IsCompleted,
|
||||
}
|
||||
if deadline := strings.TrimSpace(task.DeadlineAt); deadline != "" {
|
||||
item["deadline_at"] = deadline
|
||||
}
|
||||
taskItems = append(taskItems, item)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("找到 %d 条任务", len(results))
|
||||
if len(results) == 0 {
|
||||
title = "未找到匹配任务"
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"result_count": len(results),
|
||||
"shown_count": len(results),
|
||||
"tasks": taskItems,
|
||||
}
|
||||
queryFilters := buildTaskQueryFilters(params)
|
||||
if len(queryFilters) > 0 {
|
||||
data["query_filters"] = queryFilters
|
||||
}
|
||||
querySummary := buildTaskQuerySummary(queryFilters)
|
||||
if querySummary != "" {
|
||||
data["query_summary"] = querySummary
|
||||
}
|
||||
|
||||
return &agentstream.StreamBusinessCardExtra{
|
||||
CardType: "task_query",
|
||||
Title: title,
|
||||
Summary: querySummary,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// buildTaskQueryFilter 生成查询条件的稳定结构化描述。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. key/operator/value 提供前端可依赖的机器语义;
|
||||
// 2. label/display_text 提供前端可直接展示的中文文案;
|
||||
// 3. query_summary 只能从 display_text 派生,前端不要再反向解析 summary。
|
||||
func buildTaskQueryFilter(key string, label string, value any, operator string, displayText string) map[string]any {
|
||||
filter := map[string]any{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"display_text": strings.TrimSpace(displayText),
|
||||
}
|
||||
if strings.TrimSpace(operator) != "" {
|
||||
filter["operator"] = strings.TrimSpace(operator)
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func buildTaskQueryFilters(params agentmodel.TaskQueryParams) []map[string]any {
|
||||
filters := make([]map[string]any, 0, 6)
|
||||
if params.Quadrant != nil && *params.Quadrant >= 1 && *params.Quadrant <= 4 {
|
||||
label := agentshared.PriorityLabelCN(*params.Quadrant)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"quadrant",
|
||||
"象限",
|
||||
*params.Quadrant,
|
||||
"eq",
|
||||
fmt.Sprintf("象限:%s", label),
|
||||
))
|
||||
}
|
||||
if kw := strings.TrimSpace(params.Keyword); kw != "" {
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"keyword",
|
||||
"关键词",
|
||||
kw,
|
||||
"contains",
|
||||
fmt.Sprintf("关键词:%s", kw),
|
||||
))
|
||||
}
|
||||
if params.DeadlineAfter != nil {
|
||||
formatted := formatQuickTaskTime(params.DeadlineAfter)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"deadline_after",
|
||||
"截止起始",
|
||||
formatted,
|
||||
"gte",
|
||||
fmt.Sprintf("截止时间≥%s", formatted),
|
||||
))
|
||||
}
|
||||
if params.DeadlineBefore != nil {
|
||||
formatted := formatQuickTaskTime(params.DeadlineBefore)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"deadline_before",
|
||||
"截止结束",
|
||||
formatted,
|
||||
"lt",
|
||||
fmt.Sprintf("截止时间<%s", formatted),
|
||||
))
|
||||
}
|
||||
if !params.IncludeCompleted {
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"include_completed",
|
||||
"完成状态",
|
||||
false,
|
||||
"eq",
|
||||
"仅未完成",
|
||||
))
|
||||
}
|
||||
|
||||
sortValue := "deadline_asc"
|
||||
sortDisplay := "按截止时间升序"
|
||||
switch strings.ToLower(strings.TrimSpace(params.SortBy)) {
|
||||
case "priority":
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "priority_desc"
|
||||
sortDisplay = "按优先级降序"
|
||||
} else {
|
||||
sortValue = "priority_asc"
|
||||
sortDisplay = "按优先级升序"
|
||||
}
|
||||
case "id":
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "id_desc"
|
||||
sortDisplay = "按创建顺序倒序"
|
||||
} else {
|
||||
sortValue = "id_asc"
|
||||
sortDisplay = "按创建顺序正序"
|
||||
}
|
||||
default:
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "deadline_desc"
|
||||
sortDisplay = "按截止时间降序"
|
||||
}
|
||||
}
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"sort",
|
||||
"排序",
|
||||
sortValue,
|
||||
"eq",
|
||||
sortDisplay,
|
||||
))
|
||||
return filters
|
||||
}
|
||||
|
||||
func buildTaskQuerySummary(filters []map[string]any) string {
|
||||
parts := make([]string, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
if text, ok := filter["display_text"].(string); ok && strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(text))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// parseQuickTaskQueryDeadlineBoundary 解析 quick_task 查询时间窗边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 query 的 deadline_after/deadline_before 文本解析成时间;
|
||||
// 2. 解析失败时仅记录日志并返回 nil,不中断查询主链路;
|
||||
// 3. 不负责时间窗合法性校验(如 before<=after),该校验由调用方统一处理。
|
||||
func parseQuickTaskQueryDeadlineBoundary(raw string, field string, flowState *agentmodel.CommonState) *time.Time {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := agentshared.ParseOptionalDeadline(value)
|
||||
if err != nil {
|
||||
chatID := ""
|
||||
if flowState != nil {
|
||||
chatID = flowState.ConversationID
|
||||
}
|
||||
log.Printf("[WARN] quick_task: query %s 解析失败 chat=%s raw=%q err=%v,已降级为无该筛选条件", field, chatID, value, err)
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func formatQuickTaskTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.In(agentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
|
||||
func quickNoteFallbackPriority(deadline *time.Time) int {
|
||||
if deadline != nil {
|
||||
if time.Until(*deadline) <= 48*time.Hour {
|
||||
return agentshared.QuickNotePriorityImportantUrgent
|
||||
}
|
||||
return agentshared.QuickNotePriorityImportantNotUrgent
|
||||
}
|
||||
return agentshared.QuickNotePrioritySimpleNotImportant
|
||||
}
|
||||
394
backend/services/agent/node/rough_build.go
Normal file
394
backend/services/agent/node/rough_build.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
const (
|
||||
roughBuildStageName = "rough_build"
|
||||
roughBuildStatusBlock = "rough_build.status"
|
||||
roughBuildSampleLimit = 3
|
||||
)
|
||||
|
||||
type roughBuildApplyStats struct {
|
||||
AppliedCount int
|
||||
DayMappingMissCount int
|
||||
TaskItemMatchMissCount int
|
||||
DayMappingMissSamples []string
|
||||
TaskItemMatchMissSamples []string
|
||||
}
|
||||
|
||||
// RunRoughBuildNode 执行粗排节点逻辑。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 推送"正在粗排"状态给前端;
|
||||
// 2. 从 CommonState 读取 TaskClassIDs,确认有需要排课的任务类;
|
||||
// 3. 加载 ScheduleState(含 DayMapping);
|
||||
// 4. 调用 RoughBuildFunc 拿到粗排结果([]RoughBuildPlacement);
|
||||
// 5. 把粗排结果写入 ScheduleState,把已落位任务标记为 suggested;
|
||||
// 6. 若粗排后仍存在真实 pending,则写入正式 abort 结果并结束本轮;
|
||||
// 7. 否则按“是否需要粗排后立即微调”分流:
|
||||
// - 无明确微调诉求:直接 Done -> Deliver;
|
||||
// - 有明确微调诉求:进入 Execute。
|
||||
func RunRoughBuildNode(ctx context.Context, st *agentmodel.AgentGraphState) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("rough build node: state is nil")
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
emitter := st.EnsureChunkEmitter()
|
||||
|
||||
// 1. 推送状态:告知前端进入粗排环节。
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_building",
|
||||
"正在为你生成初始排课方案,请稍候。",
|
||||
true,
|
||||
)
|
||||
|
||||
// 2. 校验依赖。
|
||||
if st.Deps.RoughBuildFunc == nil {
|
||||
return fmt.Errorf("rough build node: RoughBuildFunc 未注入")
|
||||
}
|
||||
|
||||
// 3. 读取任务类 IDs。
|
||||
taskClassIDs := flowState.TaskClassIDs
|
||||
if len(taskClassIDs) == 0 {
|
||||
// 没有任务类 ID 时静默跳过粗排,直接进入执行阶段。
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 粗排前强制刷新 ScheduleState,避免复用旧快照窗口。
|
||||
// 4.1 设计意图:当用户做“超前规划”时,窗口必须跟随本轮 task_class_ids,而不是沿用历史“当前周”窗口。
|
||||
// 4.2 做法:主动丢弃内存中的旧 state,让 EnsureScheduleState 走 provider 重新加载。
|
||||
// 4.3 失败策略:若任务类缺少有效起止日期,provider 会返回错误,由上层统一透传并让用户补齐字段。
|
||||
st.ScheduleState = nil
|
||||
st.OriginalScheduleState = nil
|
||||
|
||||
// 5. 加载 ScheduleState(含 DayMapping,用于坐标转换)。
|
||||
scheduleState, err := st.EnsureScheduleState(ctx)
|
||||
if err != nil {
|
||||
// 1. 当任务类时间窗缺失时,按“可恢复失败”收口:提示用户先补齐起止日期,再重试粗排。
|
||||
// 2. 不把这类输入缺失上抛为系统错误,避免整条链路直接 fallback 到普通聊天。
|
||||
if strings.Contains(err.Error(), "任务类缺少有效时间窗") {
|
||||
failureMessage := "开始智能编排前,我需要任务类的起止日期(start_date / end_date)。请先补齐时间窗,再让我继续排课。"
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_build_need_time_window",
|
||||
failureMessage,
|
||||
true,
|
||||
)
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.Abort(
|
||||
roughBuildStageName,
|
||||
"rough_build_window_missing",
|
||||
failureMessage,
|
||||
err.Error(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("rough build node: 加载日程状态失败: %w", err)
|
||||
}
|
||||
if scheduleState == nil {
|
||||
return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排")
|
||||
}
|
||||
|
||||
// 6. 调用粗排算法。
|
||||
placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rough build node: 粗排算法失败: %w", err)
|
||||
}
|
||||
|
||||
// 7. 把粗排结果写入 ScheduleState。
|
||||
applyStats := applyRoughBuildPlacements(scheduleState, placements)
|
||||
|
||||
// 7.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送“排程完毕”卡片。
|
||||
if applyStats.AppliedCount > 0 {
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
// 8. 先校验粗排后是否仍有真实 pending。
|
||||
stillPending := countPendingTasks(scheduleState, taskClassIDs)
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build scope_task_classes=%v placements=%d applied=%d day_mapping_miss=%d task_item_match_miss=%d pending_in_scope=%d total_tasks=%d window_days=%d",
|
||||
taskClassIDs,
|
||||
len(placements),
|
||||
applyStats.AppliedCount,
|
||||
applyStats.DayMappingMissCount,
|
||||
applyStats.TaskItemMatchMissCount,
|
||||
stillPending,
|
||||
len(scheduleState.Tasks),
|
||||
len(scheduleState.Window.DayMapping),
|
||||
)
|
||||
if applyStats.DayMappingMissCount > 0 {
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build day_mapping_miss_samples=%v window=%s",
|
||||
applyStats.DayMappingMissSamples,
|
||||
summarizeRoughBuildWindow(scheduleState),
|
||||
)
|
||||
}
|
||||
if applyStats.TaskItemMatchMissCount > 0 {
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build task_item_match_miss_samples=%v scoped_task_samples=%v",
|
||||
applyStats.TaskItemMatchMissSamples,
|
||||
collectScopedTaskSamples(scheduleState, taskClassIDs),
|
||||
)
|
||||
}
|
||||
if stillPending > 0 {
|
||||
failureMessage := fmt.Sprintf(
|
||||
"初始排课方案构建异常:粗排后仍有 %d 个任务未获得初始落位。按当前规则,本轮不进入微调,请检查粗排算法或任务数据。",
|
||||
stillPending,
|
||||
)
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_build_failed",
|
||||
failureMessage,
|
||||
true,
|
||||
)
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.Abort(
|
||||
roughBuildStageName,
|
||||
"rough_build_pending_remaining",
|
||||
failureMessage,
|
||||
fmt.Sprintf("rough build finished with %d real pending tasks remaining", stillPending),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 8. 计算是否需要“粗排后立即微调”。
|
||||
//
|
||||
// 1. 只在“无计划直执行”链路下应用该止血分流;
|
||||
// 2. 有计划链路依旧进入 execute,避免改变既有 plan->execute 语义;
|
||||
// 3. chat 路由明确标记 needs_refine_after_rough_build=true 时才进微调。
|
||||
shouldRefineAfterRoughBuild := flowState.HasPlan() || flowState.NeedsRefineAfterRoughBuild
|
||||
|
||||
// 9. 推送完成状态(区分“继续微调”与“直接收口”两种路径)。
|
||||
doneStatus := "rough_build_done"
|
||||
doneMessage := fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排,进入微调阶段。", len(placements))
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
doneStatus = "rough_build_done_no_refine"
|
||||
doneMessage = fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排。本轮按默认策略先结束;如需优化,请继续告诉我你的偏好。", len(placements))
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
doneStatus,
|
||||
doneMessage,
|
||||
false,
|
||||
)
|
||||
|
||||
// 10. 把粗排完成信息写入 pinned context,让后续节点能拿到一致事实。
|
||||
|
||||
// 构造任务类 ID 字符串,供 pinned block 明确标注,避免 Execute LLM 因找不到 task_class_id 来源而 ask_user。
|
||||
idParts := make([]string, len(taskClassIDs))
|
||||
for i, id := range taskClassIDs {
|
||||
idParts[i] = strconv.Itoa(id)
|
||||
}
|
||||
idStr := strings.Join(idParts, ", ")
|
||||
|
||||
pinnedContent := fmt.Sprintf(
|
||||
"后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
|
||||
"这些预排任务已标记为 suggested,表示“可继续优化的建议落位”,不是待补排任务。\n"+
|
||||
"本轮不需要再调用 place,也无需再次触发粗排。",
|
||||
idStr, len(placements),
|
||||
)
|
||||
if shouldRefineAfterRoughBuild {
|
||||
pinnedContent += "\n请先调用 get_overview 查看整体分布,再使用 move / swap / unplace 微调不合理的位置。"
|
||||
} else {
|
||||
pinnedContent += "\n当前未收到明确微调偏好,流程将先收口;如需进一步优化,请基于本次结果提出调整要求。"
|
||||
}
|
||||
st.EnsureConversationContext().UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: "rough_build_done",
|
||||
Title: "粗排已完成",
|
||||
Content: pinnedContent,
|
||||
})
|
||||
|
||||
// 11. 清除粗排标记,并按分流结果进入执行或直接收口。
|
||||
//
|
||||
// 1. 无明确微调诉求:直接标记 completed,graph 会路由到 deliver;
|
||||
// 2. 有明确微调诉求:进入 execute 节点继续工具微调;
|
||||
// 3. 无论哪条路径,都要重置粗排相关标记,避免污染后续轮次。
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
flowState.ActiveOptimizeOnly = false
|
||||
flowState.Done()
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(flowState.OptimizationMode) == "" {
|
||||
flowState.OptimizationMode = "first_full"
|
||||
}
|
||||
// 1. 仅“粗排后自动进入微调”的链路打开主动优化专用模式。
|
||||
// 2. 该模式会把 execute 裁成 analyze_health + move + swap 的最小工具面,
|
||||
// 迫使 LLM 基于候选做选择,而不是重新全窗乱搜。
|
||||
// 3. 用户后续重开新请求时,会在 CommonState 的重置入口统一清掉这个标记。
|
||||
flowState.ActiveOptimizeOnly = true
|
||||
// 12. 粗排后进入 execute 微调时,补一条一次性 context hook。
|
||||
//
|
||||
// 1. 目的:即使这条链路不回 plan,也能在 execute 首轮拿到建议工具面(analyze + mutation)。
|
||||
// 2. 边界:这里只写“建议激活域/包”,不直接执行 context_tools_add,仍由 execute 按统一入口消费。
|
||||
// 3. 回退:hook 无效时 execute 会自动忽略并清空,不影响主流程。
|
||||
flowState.PendingContextHook = &agentmodel.ContextHook{
|
||||
Domain: agenttools.ToolDomainSchedule,
|
||||
Packs: []string{
|
||||
agenttools.ToolPackAnalyze,
|
||||
agenttools.ToolPackMutation,
|
||||
},
|
||||
Reason: "rough_build_post_refine",
|
||||
}
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
return nil
|
||||
}
|
||||
|
||||
// countPendingTasks 统计粗排后仍无位置的待安排任务数。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 第一轮修复后,粗排成功会把任务直接标记为 suggested;
|
||||
// 2. 为兼容旧快照,仍按“pending 且 Slots 为空”认定真正未覆盖;
|
||||
// 3. 只要这里仍大于 0,就应视为粗排异常,而不是交给 LLM 补排。
|
||||
func countPendingTasks(state *schedule.ScheduleState, taskClassIDs []int) int {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if !schedule.IsPendingTask(task) {
|
||||
continue
|
||||
}
|
||||
if len(taskClassIDs) > 0 && !schedule.IsTaskInRequestedClassScope(task, taskClassIDs) {
|
||||
continue
|
||||
}
|
||||
if schedule.IsPendingTask(task) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// applyRoughBuildPlacements 把粗排结果写入 ScheduleState 对应任务的 Slots。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 通过 task_item_id(SourceID)定位任务;
|
||||
// 2. 用 DayMapping 把 (week, dayOfWeek) 转为 day_index;
|
||||
// 3. 对成功落位的任务写入 Slots,并显式标记为 suggested;
|
||||
// 4. suggested 表示“粗排建议位”,后续可用 move/swap/unplace 微调;
|
||||
// 5. 转换失败的条目静默跳过,不中断整体流程。
|
||||
func applyRoughBuildPlacements(
|
||||
state *schedule.ScheduleState,
|
||||
placements []agentmodel.RoughBuildPlacement,
|
||||
) roughBuildApplyStats {
|
||||
stats := roughBuildApplyStats{}
|
||||
if state == nil {
|
||||
return stats
|
||||
}
|
||||
|
||||
taskIndexByItemID := make(map[int][]int)
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if task.Source != "task_item" {
|
||||
continue
|
||||
}
|
||||
taskIndexByItemID[task.SourceID] = append(taskIndexByItemID[task.SourceID], i)
|
||||
}
|
||||
|
||||
for _, p := range placements {
|
||||
day, ok := state.WeekDayToDay(p.Week, p.DayOfWeek)
|
||||
if !ok {
|
||||
stats.DayMappingMissCount++
|
||||
stats.DayMappingMissSamples = appendPlacementSample(stats.DayMappingMissSamples, p)
|
||||
continue // DayMapping 里没有对应 day,跳过
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, index := range taskIndexByItemID[p.TaskItemID] {
|
||||
t := &state.Tasks[index]
|
||||
t.Slots = []schedule.TaskSlot{
|
||||
{Day: day, SlotStart: p.SectionFrom, SlotEnd: p.SectionTo},
|
||||
}
|
||||
t.Status = schedule.TaskStatusSuggested
|
||||
stats.AppliedCount++
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
stats.TaskItemMatchMissCount++
|
||||
stats.TaskItemMatchMissSamples = appendPlacementSample(stats.TaskItemMatchMissSamples, p)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// appendPlacementSample 记录有限数量的 miss 样本,避免 debug 日志爆量。
|
||||
func appendPlacementSample(samples []string, placement agentmodel.RoughBuildPlacement) []string {
|
||||
if len(samples) >= roughBuildSampleLimit {
|
||||
return samples
|
||||
}
|
||||
return append(samples, fmt.Sprintf(
|
||||
"task_item_id=%d week=%d day=%d sections=%d-%d",
|
||||
placement.TaskItemID,
|
||||
placement.Week,
|
||||
placement.DayOfWeek,
|
||||
placement.SectionFrom,
|
||||
placement.SectionTo,
|
||||
))
|
||||
}
|
||||
|
||||
// summarizeRoughBuildWindow 提供 DayMapping 的紧凑摘要,便于判断窗口是否退化到错误周。
|
||||
func summarizeRoughBuildWindow(state *schedule.ScheduleState) string {
|
||||
if state == nil || len(state.Window.DayMapping) == 0 {
|
||||
return "empty"
|
||||
}
|
||||
first := state.Window.DayMapping[0]
|
||||
last := state.Window.DayMapping[len(state.Window.DayMapping)-1]
|
||||
return fmt.Sprintf(
|
||||
"days=%d first=W%dD%d last=W%dD%d",
|
||||
len(state.Window.DayMapping),
|
||||
first.Week,
|
||||
first.DayOfWeek,
|
||||
last.Week,
|
||||
last.DayOfWeek,
|
||||
)
|
||||
}
|
||||
|
||||
// collectScopedTaskSamples 提供当前 state 中可用于匹配的 task_item 样本,便于排查 ID 对不上。
|
||||
func collectScopedTaskSamples(state *schedule.ScheduleState, taskClassIDs []int) []string {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
samples := make([]string, 0, roughBuildSampleLimit)
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if task.Source != "task_item" {
|
||||
continue
|
||||
}
|
||||
if len(taskClassIDs) > 0 && !schedule.IsTaskInRequestedClassScope(task, taskClassIDs) {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, fmt.Sprintf(
|
||||
"source_id=%d task_class_id=%d status=%s name=%q",
|
||||
task.SourceID,
|
||||
task.TaskClassID,
|
||||
task.Status,
|
||||
task.Name,
|
||||
))
|
||||
if len(samples) >= roughBuildSampleLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return samples
|
||||
}
|
||||
21
backend/services/agent/node/speak_text.go
Normal file
21
backend/services/agent/node/speak_text.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// listItemRe 匹配被粘连在一起的列表序号,用于正文归一化时自动补换行。
|
||||
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
|
||||
|
||||
// normalizeSpeak 统一整理要展示给用户的正文。
|
||||
func normalizeSpeak(speak string) string {
|
||||
speak = strings.TrimSpace(speak)
|
||||
if speak == "" {
|
||||
return speak
|
||||
}
|
||||
if !strings.Contains(speak, "\n") {
|
||||
speak = listItemRe.ReplaceAllString(speak, "$1\n$2")
|
||||
}
|
||||
return speak + "\n"
|
||||
}
|
||||
301
backend/services/agent/node/unified_compact.go
Normal file
301
backend/services/agent/node/unified_compact.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// UnifiedCompactInput 是统一压缩入口的参数。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 从 ExecuteNodeInput 中提取压缩所需的公共字段,消除对 Execute 的直接依赖;
|
||||
// 2. 各节点(Plan/Chat/Deliver)构造此参数时从自己的 NodeInput 中提取对应字段;
|
||||
// 3. StageName 和 StatusBlockID 用于区分日志来源和 SSE 状态推送。
|
||||
type UnifiedCompactInput struct {
|
||||
// Client 用于调用 LLM 压缩 msg1/msg2。
|
||||
Client *llmservice.Client
|
||||
// CompactionStore 用于持久化压缩摘要和 token 统计,为 nil 时跳过持久化。
|
||||
CompactionStore agentmodel.CompactionStore
|
||||
// FlowState 提供 userID / chatID / roundUsed 等定位信息。
|
||||
FlowState *agentmodel.CommonState
|
||||
// Emitter 用于推送压缩进度 SSE 事件。
|
||||
Emitter *agentstream.ChunkEmitter
|
||||
// StageName 标识当前阶段(如 "execute"/"plan"/"chat"/"deliver"),用于日志和缓存 key。
|
||||
StageName string
|
||||
// StatusBlockID 是 SSE 状态推送的 block ID,各节点使用自己的 block ID。
|
||||
StatusBlockID string
|
||||
}
|
||||
|
||||
// compactUnifiedMessagesIfNeeded 检查统一消息结构的 token 预算,
|
||||
// 超限时对 msg1(历史对话)和 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 消息布局约定(由 buildUnifiedStageMessages 返回):
|
||||
//
|
||||
// [0] system — msg0: 系统规则 + 工具简表
|
||||
// [1] assistant — msg1: 历史对话上下文
|
||||
// [2] assistant — msg2: 阶段工作区(Execute=ReAct Loop,其余="暂无")
|
||||
// [3] system — msg3: 阶段状态 + 记忆 + 指令
|
||||
//
|
||||
// 压缩策略:
|
||||
// 1. msg1 超过可用预算一半时触发 LLM 压缩(合并已有摘要 + 新内容);
|
||||
// 2. msg1 压缩后仍超限,则对 msg2 也做 LLM 压缩;
|
||||
// 3. 压缩结果持久化到 CompactionStore,下一轮可复用摘要避免重复计算。
|
||||
func compactUnifiedMessagesIfNeeded(
|
||||
ctx context.Context,
|
||||
messages []*schema.Message,
|
||||
input UnifiedCompactInput,
|
||||
) []*schema.Message {
|
||||
if input.FlowState == nil {
|
||||
log.Printf("[COMPACT:%s] FlowState is nil, skip token stats refresh", input.StageName)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 1. 非严格 4 段式时,退化成按角色汇总的统计,确保 context_token_stats 仍然刷新。
|
||||
if len(messages) != 4 {
|
||||
breakdown := estimateFallbackStageTokenBreakdown(messages)
|
||||
log.Printf(
|
||||
"[COMPACT:%s] fallback token stats refresh: total=%d budget=%d count=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, len(messages),
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 2. 提取四条消息的文本内容。
|
||||
msg0 := messages[0].Content
|
||||
msg1 := messages[1].Content
|
||||
msg2 := messages[2].Content
|
||||
msg3 := messages[3].Content
|
||||
|
||||
// 3. Token 预算检查。
|
||||
breakdown, overBudget, needCompactMsg1, needCompactMsg2 := pkg.CheckStageTokenBudget(msg0, msg1, msg2, msg3)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] token budget check: total=%d budget=%d over=%v compactMsg1=%v compactMsg2=%v (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, overBudget, needCompactMsg1, needCompactMsg2,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
|
||||
if !overBudget {
|
||||
// 4. 未超限,记录 token 分布后直接返回。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 5. msg1 压缩(历史对话 → LLM 摘要)。
|
||||
if needCompactMsg1 {
|
||||
msg1 = compactUnifiedMsg1(ctx, input, msg1)
|
||||
messages[1].Content = msg1
|
||||
// 压缩 msg1 后重算预算。
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 6. msg2 压缩(阶段工作区 → LLM 摘要)。
|
||||
if needCompactMsg2 || breakdown.Total > pkg.StageTokenBudget {
|
||||
msg2 = compactUnifiedMsg2(ctx, input, msg2)
|
||||
messages[2].Content = msg2
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 7. 记录最终 token 分布。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] after compaction: total=%d budget=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
return messages
|
||||
}
|
||||
|
||||
// estimateFallbackStageTokenBreakdown 在非统一 4 段式场景下按消息角色做近似统计。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先按消息类型汇总 token,保证总量准确;
|
||||
// 2. 再把最后一个 user 消息尽量视作 msg3,保留阶段指令语义;
|
||||
// 3. 其他历史内容归入 msg1 / msg2,确保上下文统计不会因为结构不标准而断更。
|
||||
func estimateFallbackStageTokenBreakdown(messages []*schema.Message) pkg.StageTokenBreakdown {
|
||||
breakdown := pkg.StageTokenBreakdown{Budget: pkg.StageTokenBudget}
|
||||
if len(messages) == 0 {
|
||||
return breakdown
|
||||
}
|
||||
|
||||
lastUserIndex := -1
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.Role == schema.User {
|
||||
lastUserIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for i, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
tokens := pkg.EstimateMessageTokens(msg)
|
||||
breakdown.Total += tokens
|
||||
|
||||
switch msg.Role {
|
||||
case schema.System:
|
||||
breakdown.Msg0 += tokens
|
||||
case schema.User:
|
||||
if i == lastUserIndex {
|
||||
breakdown.Msg3 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
case schema.Tool:
|
||||
breakdown.Msg2 += tokens
|
||||
case schema.Assistant:
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
breakdown.Msg2 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
default:
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
// compactUnifiedMsg1 对 msg1(历史对话)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 先加载该阶段已有的压缩摘要,与当前 msg1 合并后调 LLM 压缩;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程;
|
||||
// 4. 压缩成功后持久化新摘要,供下一轮复用。
|
||||
func compactUnifiedMsg1(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg1 string,
|
||||
) string {
|
||||
// 1. CompactionStore 为 nil 时无法加载/保存摘要,跳过压缩。
|
||||
if input.CompactionStore == nil {
|
||||
log.Printf("[COMPACT:%s] CompactionStore is nil, skip msg1 compaction", input.StageName)
|
||||
return msg1
|
||||
}
|
||||
|
||||
// 2. 加载该阶段已有的压缩摘要(可能为空)。
|
||||
existingSummary, _, err := input.CompactionStore.LoadStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] load existing compaction failed: %v, proceed without cache", input.StageName, err)
|
||||
}
|
||||
|
||||
// 3. SSE: 压缩开始。
|
||||
tokenBefore := pkg.EstimateTextTokens(msg1)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩对话历史(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
// 4. 调用 LLM 压缩:将 msg1 全文 + 已有摘要合并为一份紧凑摘要。
|
||||
newSummary, err := agentprompt.CompactMsg1(ctx, input.Client, msg1, existingSummary)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg1 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"对话历史压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg1
|
||||
}
|
||||
|
||||
// 5. SSE: 压缩完成。
|
||||
tokenAfter := pkg.EstimateTextTokens(newSummary)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("对话历史已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
// 6. 持久化压缩结果,下一轮可直接复用摘要。
|
||||
if err := input.CompactionStore.SaveStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName, newSummary, input.FlowState.RoundUsed); err != nil {
|
||||
log.Printf("[COMPACT:%s] save compaction failed: %v", input.StageName, err)
|
||||
}
|
||||
|
||||
return newSummary
|
||||
}
|
||||
|
||||
// compactUnifiedMsg2 对 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 非 Execute 阶段的 msg2 通常是"暂无",压缩无意义但不会出错;
|
||||
// 2. Execute 阶段的 msg2 包含 ReAct loop 记录,压缩可显著节省 token;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程。
|
||||
func compactUnifiedMsg2(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg2 string,
|
||||
) string {
|
||||
// 1. SSE: 压缩开始。
|
||||
tokenBefore := pkg.EstimateTextTokens(msg2)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩执行记录(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
// 2. 调用 LLM 压缩。
|
||||
compressed, err := agentprompt.CompactMsg2(ctx, input.Client, msg2)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg2 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"执行记录压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg2
|
||||
}
|
||||
|
||||
// 3. SSE: 压缩完成。
|
||||
tokenAfter := pkg.EstimateTextTokens(compressed)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("执行记录已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
return compressed
|
||||
}
|
||||
|
||||
// saveUnifiedTokenStats 持久化当前 token 分布到 DB。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 序列化失败只记日志,不中断主流程;
|
||||
// 3. 写入失败只记日志,不中断主流程。
|
||||
func saveUnifiedTokenStats(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
breakdown pkg.StageTokenBreakdown,
|
||||
) {
|
||||
if input.CompactionStore == nil || input.FlowState == nil {
|
||||
return
|
||||
}
|
||||
statsJSON, err := json.Marshal(breakdown)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] marshal token stats failed: %v", input.StageName, err)
|
||||
return
|
||||
}
|
||||
if err := input.CompactionStore.SaveContextTokenStats(ctx, input.FlowState.UserID, input.FlowState.ConversationID, string(statsJSON)); err != nil {
|
||||
log.Printf("[COMPACT:%s] save token stats failed: %v", input.StageName, err)
|
||||
}
|
||||
}
|
||||
37
backend/services/agent/node/visible_message.go
Normal file
37
backend/services/agent/node/visible_message.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// persistVisibleAssistantMessage 负责把“真正要展示给用户”的 assistant 文本交给 service 层持久化。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理可见的 assistant 消息,不处理内部纠错提示、工具调用结果和纯状态文案;
|
||||
// 2. 持久化失败只记日志,不反向中断节点主流程,避免“已经对外输出但后端补写失败”时把用户请求打断;
|
||||
// 3. 具体的 Redis / MySQL / 乐观缓存写入由 service 回调统一完成。
|
||||
func persistVisibleAssistantMessage(
|
||||
ctx context.Context,
|
||||
persist agentmodel.PersistVisibleMessageFunc,
|
||||
state *agentmodel.CommonState,
|
||||
msg *schema.Message,
|
||||
) {
|
||||
if persist == nil || state == nil || msg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(string(msg.Role))
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if role != string(schema.Assistant) || content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := persist(ctx, state, msg); err != nil {
|
||||
log.Printf("[WARN] persist visible assistant message failed chat=%s phase=%s err=%v", state.ConversationID, state.Phase, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user