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:
86
backend/services/agent/model/chat_contract.go
Normal file
86
backend/services/agent/model/chat_contract.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChatRoute 表示 Chat 节点路由决策的目标路径。
|
||||
type ChatRoute string
|
||||
|
||||
const (
|
||||
// ChatRouteDirectReply 简单任务:Chat 节点直接输出回复,不再调用下游节点。
|
||||
ChatRouteDirectReply ChatRoute = "direct_reply"
|
||||
|
||||
// ChatRouteExecute 中等任务:需要用工具处理,直接进 Execute ReAct 循环。
|
||||
ChatRouteExecute ChatRoute = "execute"
|
||||
|
||||
// ChatRouteDeepAnswer 复杂问答:需要深度思考但不需工具,Chat 节点原地开 thinking 回答。
|
||||
ChatRouteDeepAnswer ChatRoute = "deep_answer"
|
||||
|
||||
// ChatRoutePlan 复杂规划:需要先制定计划,进 Plan 节点。
|
||||
ChatRoutePlan ChatRoute = "plan"
|
||||
|
||||
// ChatRouteQuickTask 快捷任务:随口记增查改删等轻量任务操作,走 QuickTask 轻量路径。
|
||||
ChatRouteQuickTask ChatRoute = "quick_task"
|
||||
)
|
||||
|
||||
// ChatRoutingDecision 是 Chat 节点单次路由决策的结构化输出。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Route 决定后续处理路径;
|
||||
// 2. NeedsRoughBuild 仅在 route=execute 且满足粗排条件时为 true;
|
||||
// 3. NeedsRefineAfterRoughBuild 仅在 needs_rough_build=true 时有效;
|
||||
// 4. AllowReorder 表示是否允许打乱 suggested 任务顺序,仅用户明确授权时应为 true;
|
||||
// 5. Thinking 表示下游 Execute 节点是否应开启深度思考;
|
||||
// 6. Raw 保留控制码原文,供日志排查;
|
||||
// 7. 用户可见内容(speak)由流式输出自然产出,不由本结构承载。
|
||||
type ChatRoutingDecision struct {
|
||||
Route ChatRoute
|
||||
NeedsRoughBuild bool
|
||||
NeedsRefineAfterRoughBuild bool
|
||||
AllowReorder bool
|
||||
Thinking bool
|
||||
Raw string
|
||||
}
|
||||
|
||||
// Normalize 统一清洗路由决策中的字符串字段。
|
||||
func (d *ChatRoutingDecision) Normalize() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.Route = ChatRoute(strings.TrimSpace(string(d.Route)))
|
||||
d.Raw = strings.TrimSpace(d.Raw)
|
||||
}
|
||||
|
||||
// Validate 校验路由决策的最小合法性。
|
||||
func (d *ChatRoutingDecision) Validate() error {
|
||||
if d == nil {
|
||||
return fmt.Errorf("chat routing decision 不能为空")
|
||||
}
|
||||
|
||||
d.Normalize()
|
||||
|
||||
switch d.Route {
|
||||
case ChatRouteDirectReply, ChatRouteExecute, ChatRouteDeepAnswer, ChatRoutePlan, ChatRouteQuickTask:
|
||||
// ok
|
||||
case "":
|
||||
return fmt.Errorf("chat routing decision.route 不能为空")
|
||||
default:
|
||||
return fmt.Errorf("未知 route: %s", d.Route)
|
||||
}
|
||||
|
||||
// 非 execute 路由不应携带粗排和粗排后微调标记,统一归一化为 false。
|
||||
if d.Route != ChatRouteExecute {
|
||||
d.NeedsRoughBuild = false
|
||||
d.NeedsRefineAfterRoughBuild = false
|
||||
d.AllowReorder = false
|
||||
d.Thinking = false
|
||||
}
|
||||
// 只有 needs_rough_build=true 时,needs_refine_after_rough_build 才有语义。
|
||||
if !d.NeedsRoughBuild {
|
||||
d.NeedsRefineAfterRoughBuild = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
512
backend/services/agent/model/common_state.go
Normal file
512
backend/services/agent/model/common_state.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// Phase 表示 agent 主循环当前所处的大阶段。
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlanning Phase = "planning"
|
||||
PhaseWaitingConfirm Phase = "waiting_confirm"
|
||||
PhaseExecuting Phase = "executing"
|
||||
PhaseQuickTask Phase = "quick_task"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// FlowTerminalStatus 表示本轮流程最终是如何结束的。
|
||||
//
|
||||
// 说明:
|
||||
// 1. completed 表示任务按预期完成,允许走正常交付与预览落盘;
|
||||
// 2. aborted 表示业务语义上的主动终止,例如粗排异常、执行期明确中止;
|
||||
// 3. exhausted 表示安全边界触发的被动停止,例如执行轮次耗尽。
|
||||
type FlowTerminalStatus string
|
||||
|
||||
const (
|
||||
FlowTerminalStatusCompleted FlowTerminalStatus = "completed"
|
||||
FlowTerminalStatusAborted FlowTerminalStatus = "aborted"
|
||||
FlowTerminalStatusExhausted FlowTerminalStatus = "exhausted"
|
||||
)
|
||||
|
||||
// FlowTerminalOutcome 保存"流程为什么结束"的最终结果快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Stage 说明终止发生在哪个阶段,便于 graph/deliver/debug 统一收口;
|
||||
// 2. Code 作为稳定机器码,便于后续前端或埋点按类型识别;
|
||||
// 3. UserMessage 是最终给用户看的收口文案;
|
||||
// 4. InternalReason 只用于日志与排查,不直接暴露给用户。
|
||||
type FlowTerminalOutcome struct {
|
||||
Status FlowTerminalStatus `json:"status"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
UserMessage string `json:"user_message,omitempty"`
|
||||
InternalReason string `json:"internal_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗终止结果里的字符串字段。
|
||||
func (o *FlowTerminalOutcome) Normalize() {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
o.Status = FlowTerminalStatus(strings.TrimSpace(string(o.Status)))
|
||||
o.Stage = strings.TrimSpace(o.Stage)
|
||||
o.Code = strings.TrimSpace(o.Code)
|
||||
o.UserMessage = strings.TrimSpace(o.UserMessage)
|
||||
o.InternalReason = strings.TrimSpace(o.InternalReason)
|
||||
}
|
||||
|
||||
const DefaultMaxRounds = 60
|
||||
|
||||
// CommonState 承载可持久化的主流程状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责记录"当前处于哪个阶段、当前计划是什么、执行到了第几步、已经消耗了多少轮";
|
||||
// 2. 负责提供最小必要的安全访问方法,避免 graph/node/prompt 层到处手写切片越界判断;
|
||||
// 3. 不负责承载对话历史、tool schema、pinned context 这类模型输入材料,它们仍然属于 ConversationContext。
|
||||
type CommonState struct {
|
||||
// 身份信息
|
||||
TraceID string `json:"trace_id"`
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
// ActiveToolDomain 记录当前 msg0 动态区激活的业务工具域。
|
||||
// 说明:
|
||||
// 1. 空字符串表示仅保留 context 管理工具,不注入业务工具定义;
|
||||
// 2. 非空时仅允许注入对应域的工具(如 schedule/taskclass);
|
||||
// 3. 该字段由 context_tools_add/remove 工具结果驱动更新。
|
||||
ActiveToolDomain string `json:"active_tool_domain,omitempty"`
|
||||
// ActiveToolPacks 记录当前激活域下的可选二级包(不含 core 固定包)。
|
||||
// 说明:
|
||||
// 1. 仅对 schedule 域生效(queue/mutation/analyze/web);
|
||||
// 2. 为空时按域默认策略解释(schedule 兼容为“全可选包”);
|
||||
// 3. 该字段与 ActiveToolDomain 一起由 context_tools_add/remove 结果更新。
|
||||
ActiveToolPacks []string `json:"active_tool_packs,omitempty"`
|
||||
// PendingContextHook 保存 plan 阶段给 execute 阶段的一次性注入建议。
|
||||
// 说明:
|
||||
// 1. 可由 plan_done 或 rough_build->execute 分支写入;
|
||||
// 2. execute 首轮消费一次后清空;
|
||||
// 3. 该字段只表达建议,不直接触发工具调用。
|
||||
PendingContextHook *ContextHook `json:"pending_context_hook,omitempty"`
|
||||
|
||||
// 流程阶段
|
||||
Phase Phase `json:"phase"`
|
||||
|
||||
// 计划状态
|
||||
// 1. 这里直接使用结构化的 PlanStep,避免 planning -> execute 之间丢失 done_when。
|
||||
// 2. CurrentStep 表示"当前 plan 步骤下标",不是 execute 内部 ReAct 的思考轮次。
|
||||
PlanSteps []PlanStep `json:"plan_steps"`
|
||||
CurrentStep int `json:"current_step"`
|
||||
|
||||
// 安全边界
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
RoundUsed int `json:"round_used"`
|
||||
|
||||
// 连续修正计数:LLM 连续输出不合法决策的次数,超过阈值后强制终止避免死循环。
|
||||
ConsecutiveCorrections int `json:"consecutive_corrections"`
|
||||
|
||||
// TaskClassIDs 本次排课请求涉及的任务类 ID 列表,由前端 extra.task_class_ids 传入。
|
||||
// Plan 节点据此判断是否需要粗排;跨轮次持久化,不会因会话恢复而丢失。
|
||||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||||
// TaskClasses 本次排课涉及的任务类约束元数据(含日期、策略、时段预算等),
|
||||
// 在 Service 层从 DB 加载并注入,供 Plan prompt 直接消费,避免 LLM 因信息不足而追问用户。
|
||||
TaskClasses []schedule.TaskClassMeta `json:"task_classes,omitempty"`
|
||||
// NeedsRoughBuild 由 Plan 节点在 plan_done 时写入,标记 Confirm 后是否需要走粗排节点。
|
||||
// 粗排节点执行完毕后会将此字段重置为 false。
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
// NeedsRefineAfterRoughBuild 表示"粗排完成后是否需要立即进入微调"。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该标记主要用于 chat->execute 的直执行链路;
|
||||
// 2. true 表示用户已明确提出优化偏好,粗排后继续进 execute 微调;
|
||||
// 3. false 表示用户仅要求完成排入,粗排成功后可直接收口,等待后续再优化。
|
||||
NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"`
|
||||
// AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。
|
||||
// 默认 false,只有用户明确说明"可以打乱顺序/顺序不重要"才会为 true。
|
||||
AllowReorder bool `json:"allow_reorder,omitempty"`
|
||||
OptimizationMode string `json:"optimization_mode,omitempty"`
|
||||
// ActiveOptimizeOnly 标记“当前是否处于粗排后主动优化专用模式”。
|
||||
// 1. true 时,execute 只向 LLM 暴露 analyze_health + move + swap 这组最小闭环工具;
|
||||
// 2. 该开关只用于首次粗排后的自动微调,不影响用户后续明确提出的日程调整请求;
|
||||
// 3. 流程收口、重开新请求或切换业务域后,必须重置为 false。
|
||||
ActiveOptimizeOnly bool `json:"active_optimize_only,omitempty"`
|
||||
HealthCheckDone bool `json:"health_check_done,omitempty"`
|
||||
HealthIsFeasible bool `json:"health_is_feasible,omitempty"`
|
||||
HealthCapacityGap int `json:"health_capacity_gap,omitempty"`
|
||||
HealthReasonCode string `json:"health_reason_code,omitempty"`
|
||||
// HealthShouldContinueOptimize 记录最近一次 analyze_health 是否认为“还值得继续优化”。
|
||||
// 调用目的:
|
||||
// 1. 让 execute prompt 直接读取后端诊断结论,而不是只根据 issues 猜下一步;
|
||||
// 2. 该字段只表达“是否值得继续动”,不替 LLM 决定具体写参数;
|
||||
// 3. 默认 false,只有 analyze_health 明确判定后才会更新。
|
||||
HealthShouldContinueOptimize bool `json:"health_should_continue_optimize,omitempty"`
|
||||
// HealthTightnessLevel 记录最近一次诊断得到的优化空间等级:loose / tight / locked。
|
||||
// 调用目的:
|
||||
// 1. 用于提示 LLM 区分“还能优化”和“已经是被迫不完美”;
|
||||
// 2. 该字段只服务主动优化链路,不参与粗排可行性判断;
|
||||
// 3. 空字符串表示尚未拿到有效诊断。
|
||||
HealthTightnessLevel string `json:"health_tightness_level,omitempty"`
|
||||
// HealthPrimaryProblem 保存最近一次诊断的主要局部问题摘要。
|
||||
// 调用目的:
|
||||
// 1. 帮助 execute 聚焦当前最值得处理的那个点,避免全局乱搜;
|
||||
// 2. 只保存短摘要,不保存完整工具原文,避免状态膨胀;
|
||||
// 3. 为空表示当前没有明确主问题或诊断失败。
|
||||
HealthPrimaryProblem string `json:"health_primary_problem,omitempty"`
|
||||
// HealthRecommendedOperation 保存最近一次诊断建议优先考虑的动作类型。
|
||||
// 允许值由 analyze_health 控制,当前主要为 swap / move / close / ask_user。
|
||||
HealthRecommendedOperation string `json:"health_recommended_operation,omitempty"`
|
||||
// HealthIsForcedImperfection 标记当前剩余问题是否更像“约束代价”而非“仍值得修”的问题。
|
||||
// 调用目的:
|
||||
// 1. 给 LLM 一个明确的收口信号;
|
||||
// 2. 仅在 analyze_health 返回结构化 decision 时更新;
|
||||
// 3. false 不代表一定要继续优化,只代表“不是明确的被迫不完美”。
|
||||
HealthIsForcedImperfection bool `json:"health_is_forced_imperfection,omitempty"`
|
||||
// HealthImprovementSignal 保存最近一次诊断的紧凑对比信号,用于判断是否连续停滞。
|
||||
// 调用目的:
|
||||
// 1. execute 可基于该字段识别“连续两轮几乎没改善”;
|
||||
// 2. 信号由 analyze_health 生成,格式稳定但不面向用户展示;
|
||||
// 3. 若诊断失败则保持空字符串。
|
||||
HealthImprovementSignal string `json:"health_improvement_signal,omitempty"`
|
||||
// HealthStagnationCount 记录连续多少次 analyze_health 给出了相同的 improvement_signal。
|
||||
// 调用目的:
|
||||
// 1. 让 prompt 可以在“继续磨也没明显改善”时提醒 LLM 主动收口;
|
||||
// 2. 仅在两次连续有效诊断的信号完全相同时递增;
|
||||
// 3. 只做软提醒,不做后端硬拦截。
|
||||
HealthStagnationCount int `json:"health_stagnation_count,omitempty"`
|
||||
// TaskClassUpsertLastTried 标记本轮是否至少调用过一次 upsert_task_class。
|
||||
// 调用目的:execute_context 仅在该标记为 true 时注入“最近一次任务类写入结果”,避免噪音。
|
||||
TaskClassUpsertLastTried bool `json:"task_class_upsert_last_tried,omitempty"`
|
||||
// TaskClassUpsertLastSuccess 记录最近一次 upsert_task_class 是否成功。
|
||||
// 调用目的:为 prompt 提供“是否需要继续追问补字段”的明确信号。
|
||||
TaskClassUpsertLastSuccess bool `json:"task_class_upsert_last_success,omitempty"`
|
||||
// TaskClassUpsertLastIssues 记录最近一次写入返回的校验问题(validation.issues)。
|
||||
// 调用目的:让 LLM 直接按缺失字段追问,减少泛化提问。
|
||||
TaskClassUpsertLastIssues []string `json:"task_class_upsert_last_issues,omitempty"`
|
||||
// TaskClassUpsertConsecutiveFailures 记录连续写入失败次数。
|
||||
// 调用目的:给 prompt 注入“避免空转”的软提示,不做硬拦截。
|
||||
TaskClassUpsertConsecutiveFailures int `json:"task_class_upsert_consecutive_failures,omitempty"`
|
||||
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
|
||||
// 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。
|
||||
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
|
||||
// UsedQuickNote 标记本轮是否走过“快捷随口记任务”路径。
|
||||
// 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
|
||||
UsedQuickNote bool `json:"used_quick_note,omitempty"`
|
||||
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。
|
||||
// 调用目的:deliver 节点据此判断是否向前端推送"排程完毕"卡片。
|
||||
HasScheduleChanges bool `json:"has_schedule_changes,omitempty"`
|
||||
|
||||
// ExecuteThinking 由 Chat 路由决策传入,表示 Execute 节点是否应开启深度思考。
|
||||
// 预埋字段,当前阶段 Execute 节点可自行决定是否读取。
|
||||
ExecuteThinking bool `json:"execute_thinking,omitempty"`
|
||||
|
||||
// ThinkingMode 由前端传入,控制所有下游 LLM 调用的 thinking 行为。
|
||||
// "true" 强制开启,"false" 强制关闭,"auto"(默认)交给路由决策。
|
||||
ThinkingMode string `json:"thinking_mode,omitempty"`
|
||||
|
||||
// TerminalOutcome 保存"本轮流程最终如何结束"的统一收口结果。
|
||||
// 第二轮开始,rough_build / execute / deliver 都应围绕这份快照判断收口语义。
|
||||
TerminalOutcome *FlowTerminalOutcome `json:"terminal_outcome,omitempty"`
|
||||
}
|
||||
|
||||
func NewCommonState(traceID string, userID int, conversationID string) *CommonState {
|
||||
return &CommonState{
|
||||
TraceID: traceID,
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
Phase: PhasePlanning,
|
||||
MaxRounds: DefaultMaxRounds,
|
||||
}
|
||||
}
|
||||
|
||||
// NextRound 消耗一轮预算,并返回当前是否仍在允许范围内。
|
||||
func (s *CommonState) NextRound() bool {
|
||||
s.RoundUsed++
|
||||
return s.RoundUsed <= s.MaxRounds
|
||||
}
|
||||
|
||||
// Exhausted 判断是否已经耗尽轮次预算。
|
||||
func (s *CommonState) Exhausted() bool {
|
||||
return s.RoundUsed >= s.MaxRounds
|
||||
}
|
||||
|
||||
// FinishPlan 在 planning 完成后固化完整计划,并推进到待确认阶段。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 直接保存完整的 []PlanStep,避免 execute 阶段再去依赖 pinned context 回捞完成判定;
|
||||
// 2. 统一把 CurrentStep 重置到第 0 步,保证后续 confirm/execute 都从计划开头进入;
|
||||
// 3. 这里只负责状态切换,不负责刷新 ConversationContext 中的置顶 plan 文本。
|
||||
func (s *CommonState) FinishPlan(steps []PlanStep) {
|
||||
s.PlanSteps = steps
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseWaitingConfirm
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// ConfirmPlan 表示用户已确认计划,流程进入执行阶段。
|
||||
func (s *CommonState) ConfirmPlan() {
|
||||
s.Phase = PhaseExecuting
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// StartDirectExecute 进入无 plan 的直接执行(ReAct)模式。
|
||||
// Chat 节点路由到 execute 时必须调用此方法,而非直接赋值 Phase,
|
||||
// 否则上一次任务残留的 PlanSteps 会被 HasPlan() 误判为仍有计划,
|
||||
// 导致 Execute 节点用旧步骤跑 plan 模式而非 ReAct 模式。
|
||||
func (s *CommonState) StartDirectExecute() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseExecuting
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// RejectPlan 表示用户拒绝当前计划,清空计划并回退到 planning。
|
||||
func (s *CommonState) RejectPlan() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhasePlanning
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// ResetForNextRun 在"上一轮已经收口,且本轮准备开始新请求"时重置执行期临时状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责清理会污染新一轮执行的临时字段(轮次、修正计数、计划游标、粗排开关、顺序基线、终止结果);
|
||||
// 2. 不负责清理会话身份与跨轮共享数据(ConversationID/UserID/TaskClassIDs/TaskClasses/历史上下文/ScheduleState);
|
||||
// 3. 该方法是幂等操作:重复调用不会引入额外副作用,便于在"加载兜底 + chat 入口"双保险场景下复用。
|
||||
func (s *CommonState) ResetForNextRun() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 先把阶段回收为 planning,确保新一轮从可路由的干净入口开始。
|
||||
// 2. 这样即使后续还有兜底重置判断,也不会因为仍处于 done 而重复触发。
|
||||
s.Phase = PhasePlanning
|
||||
|
||||
// 3. 清理执行轮次与连续修正计数,避免上一轮预算/异常计数污染本轮。
|
||||
s.RoundUsed = 0
|
||||
s.ConsecutiveCorrections = 0
|
||||
|
||||
// 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
|
||||
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
|
||||
s.AllowReorder = false
|
||||
s.OptimizationMode = ""
|
||||
s.HealthCheckDone = false
|
||||
s.HealthIsFeasible = true
|
||||
s.HealthCapacityGap = 0
|
||||
s.HealthReasonCode = ""
|
||||
s.HealthShouldContinueOptimize = false
|
||||
s.HealthTightnessLevel = ""
|
||||
s.HealthPrimaryProblem = ""
|
||||
s.HealthRecommendedOperation = ""
|
||||
s.HealthIsForcedImperfection = false
|
||||
s.HealthImprovementSignal = ""
|
||||
s.HealthStagnationCount = 0
|
||||
s.HasScheduleWriteOps = false
|
||||
s.HasScheduleChanges = false
|
||||
s.UsedQuickNote = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// resetTaskClassUpsertSnapshot 清理“任务类写入回盘”运行态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅清理 upsert_task_class 相关的临时回盘字段;
|
||||
// 2. 不影响 Health/Plan/Phase 等其他执行状态;
|
||||
// 3. 作为新一轮入口统一调用,避免旧失败信息污染本轮追问。
|
||||
func (s *CommonState) resetTaskClassUpsertSnapshot() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.TaskClassUpsertLastTried = false
|
||||
s.TaskClassUpsertLastSuccess = false
|
||||
s.TaskClassUpsertLastIssues = nil
|
||||
s.TaskClassUpsertConsecutiveFailures = 0
|
||||
}
|
||||
|
||||
// AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。
|
||||
func (s *CommonState) AdvanceStep() bool {
|
||||
s.CurrentStep++
|
||||
return s.CurrentStep < len(s.PlanSteps)
|
||||
}
|
||||
|
||||
// Done 标记整个任务流程已经结束。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 若此前已经写入 aborted / exhausted 等终止结果,这里只负责兜底维持 PhaseDone,不覆盖已有语义;
|
||||
// 2. 只有在尚未写入任何终止结果时,才默认补成 completed。
|
||||
func (s *CommonState) Done() {
|
||||
s.Phase = PhaseDone
|
||||
// 收口时自动清空工具域,确保下一轮 msg0 动态区回到最小集合(仅 context 管理工具)。
|
||||
// 调用目的:把“收尾清理”从 LLM 决策中剥离,减少 done 阶段无关 tool_call 噪音。
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.ActiveOptimizeOnly = false
|
||||
if s.TerminalOutcome != nil {
|
||||
s.TerminalOutcome.Normalize()
|
||||
return
|
||||
}
|
||||
s.TerminalOutcome = &FlowTerminalOutcome{
|
||||
Status: FlowTerminalStatusCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
// Abort 将当前流程标记为"业务语义上的主动终止"。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 统一写入 PhaseDone,保证 graph 后续直接进入 deliver 收口;
|
||||
// 2. UserMessage 作为最终可见文案,必须尽量完整,避免 deliver 再二次猜测;
|
||||
// 3. InternalReason 只用于排查,允许比用户文案更技术化。
|
||||
func (s *CommonState) Abort(stage, code, userMessage, internalReason string) {
|
||||
s.Phase = PhaseDone
|
||||
s.TerminalOutcome = &FlowTerminalOutcome{
|
||||
Status: FlowTerminalStatusAborted,
|
||||
Stage: stage,
|
||||
Code: code,
|
||||
UserMessage: userMessage,
|
||||
InternalReason: internalReason,
|
||||
}
|
||||
s.TerminalOutcome.Normalize()
|
||||
}
|
||||
|
||||
// Exhaust 将当前流程标记为"安全边界触发的被动停止"。
|
||||
func (s *CommonState) Exhaust(stage, userMessage, internalReason string) {
|
||||
s.Phase = PhaseDone
|
||||
s.TerminalOutcome = &FlowTerminalOutcome{
|
||||
Status: FlowTerminalStatusExhausted,
|
||||
Stage: stage,
|
||||
Code: "round_exhausted",
|
||||
UserMessage: userMessage,
|
||||
InternalReason: internalReason,
|
||||
}
|
||||
s.TerminalOutcome.Normalize()
|
||||
}
|
||||
|
||||
// ClearTerminalOutcome 清空上一轮遗留的终止结果。
|
||||
func (s *CommonState) ClearTerminalOutcome() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.TerminalOutcome = nil
|
||||
}
|
||||
|
||||
// HasTerminalOutcome 判断当前是否已经写入正式终止结果。
|
||||
func (s *CommonState) HasTerminalOutcome() bool {
|
||||
return s != nil && s.TerminalOutcome != nil
|
||||
}
|
||||
|
||||
// TerminalStatus 返回当前终止结果的状态枚举。
|
||||
func (s *CommonState) TerminalStatus() FlowTerminalStatus {
|
||||
if s == nil || s.TerminalOutcome == nil {
|
||||
return ""
|
||||
}
|
||||
return s.TerminalOutcome.Status
|
||||
}
|
||||
|
||||
// IsCompleted 判断当前是否属于"正常完成"。
|
||||
func (s *CommonState) IsCompleted() bool {
|
||||
return s.TerminalStatus() == FlowTerminalStatusCompleted
|
||||
}
|
||||
|
||||
// IsAborted 判断当前是否属于"主动中止"。
|
||||
func (s *CommonState) IsAborted() bool {
|
||||
return s.TerminalStatus() == FlowTerminalStatusAborted
|
||||
}
|
||||
|
||||
// IsExhaustedTerminal 判断当前是否属于"轮次耗尽收口"。
|
||||
func (s *CommonState) IsExhaustedTerminal() bool {
|
||||
return s.TerminalStatus() == FlowTerminalStatusExhausted
|
||||
}
|
||||
|
||||
// HasPlan 判断当前 state 是否已经持有一份完整计划。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责收口"是否存在 plan"这一层判断,避免外层到处写 len(PlanSteps) > 0;
|
||||
// 2. 不判断 CurrentStep 当前是否有效,当前步骤是否合法由 HasCurrentPlanStep 回答;
|
||||
// 3. state 为空时统一返回 false,调用方可据此决定是否回退到 planning。
|
||||
func (s *CommonState) HasPlan() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return len(s.PlanSteps) > 0
|
||||
}
|
||||
|
||||
// CurrentPlanStep 返回当前正在执行的结构化计划步骤。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责根据 CurrentStep 安全读取 PlanSteps,避免 graph/node/prompt 层重复写越界判断;
|
||||
// 2. 若 state 为空、plan 为空、或当前索引越界,则统一返回 (PlanStep{}, false);
|
||||
// 3. 不负责推进步骤,也不负责修正 CurrentStep 的取值。
|
||||
func (s *CommonState) CurrentPlanStep() (PlanStep, bool) {
|
||||
if s == nil {
|
||||
return PlanStep{}, false
|
||||
}
|
||||
if s.CurrentStep < 0 || s.CurrentStep >= len(s.PlanSteps) {
|
||||
return PlanStep{}, false
|
||||
}
|
||||
return s.PlanSteps[s.CurrentStep], true
|
||||
}
|
||||
|
||||
// HasCurrentPlanStep 判断"当前步骤"是否存在且可安全读取。
|
||||
func (s *CommonState) HasCurrentPlanStep() bool {
|
||||
_, ok := s.CurrentPlanStep()
|
||||
return ok
|
||||
}
|
||||
|
||||
// PlanProgress 返回当前计划的执行进度。
|
||||
//
|
||||
// 输出语义:
|
||||
// 1. current 使用更适合给用户看的 1-based 序号;
|
||||
// 2. total 表示当前计划的总步数;
|
||||
// 3. 若当前还没有计划,则返回 (0, 0);
|
||||
// 4. 若 CurrentStep 已越界到末尾之后,则把 current 收敛到 total,避免出现 total+1 这种噪音值。
|
||||
func (s *CommonState) PlanProgress() (current int, total int) {
|
||||
if s == nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
total = len(s.PlanSteps)
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if s.CurrentStep < 0 {
|
||||
return 0, total
|
||||
}
|
||||
if s.CurrentStep >= total {
|
||||
return total, total
|
||||
}
|
||||
return s.CurrentStep + 1, total
|
||||
}
|
||||
212
backend/services/agent/model/conversation_context.go
Normal file
212
backend/services/agent/model/conversation_context.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ConversationContext 承载"本轮要喂给模型的输入材料"。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责保存 system prompt、对话历史、置顶注入块、工具 schema 摘要;
|
||||
// 2. 负责提供最小必要的安全访问方法,避免 node / prompt 层直接散落切片操作;
|
||||
// 3. 不负责流程推进,phase / round / current step 仍归 CommonState 管;
|
||||
// 4. 不负责真正的 prompt 组装,消息如何拼接仍应放在 prompt 层处理。
|
||||
type ConversationContext struct {
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
History []*schema.Message `json:"history"`
|
||||
PinnedBlocks []ContextBlock `json:"pinned_blocks"`
|
||||
ToolSchemas []ToolSchemaContext `json:"-"` // 每次请求由 Service 层重新注入,不持久化
|
||||
}
|
||||
|
||||
// ContextBlock 表示一段可被"置顶注入"的自然语言上下文。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. Key 用于让调用方按语义覆盖,例如 current_plan / current_step / execution_rule;
|
||||
// 2. Title 用于 prompt 层后续决定是否渲染成小标题;
|
||||
// 3. Content 存真正的自然语言内容,保持你当前"plan 用自然语言表达"的思路。
|
||||
type ContextBlock struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ToolSchemaContext 是工具描述的轻量快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只保留 prompt 注入真正需要的摘要信息;
|
||||
// 2. SchemaText 约定存"已经整理好的自然语言 / JSON schema 摘要";
|
||||
// 3. 不直接耦合具体 tool registry 里的复杂结构,避免 model 层反向依赖工具实现。
|
||||
type ToolSchemaContext struct {
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
SchemaText string `json:"schema_text"`
|
||||
}
|
||||
|
||||
// NewConversationContext 创建最小上下文容器。
|
||||
func NewConversationContext(systemPrompt string) *ConversationContext {
|
||||
return &ConversationContext{
|
||||
SystemPrompt: strings.TrimSpace(systemPrompt),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSystemPrompt 更新系统提示词。
|
||||
func (c *ConversationContext) SetSystemPrompt(systemPrompt string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.SystemPrompt = strings.TrimSpace(systemPrompt)
|
||||
}
|
||||
|
||||
// ReplaceHistory 整体替换对话历史。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把"会话快照恢复"这类场景需要的一次性覆盖入口收口到这里;
|
||||
// 2. 只复制消息切片本身,避免调用方后续 append 污染同一底层数组;
|
||||
// 3. 不深拷贝每个 message 指针,消息对象本身仍默认由上游按只读方式使用。
|
||||
func (c *ConversationContext) ReplaceHistory(history []*schema.Message) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.History = cloneMessageSlice(history)
|
||||
}
|
||||
|
||||
// AppendHistory 追加对话历史。
|
||||
//
|
||||
// 处理策略:
|
||||
// 1. 跳过 nil message,避免后续 prompt 拼装时出现空指针;
|
||||
// 2. 仅负责顺序追加,不做去重,不做裁剪;
|
||||
// 3. 历史裁剪策略属于后续 prompt / memory 层能力,此处先不下沉。
|
||||
func (c *ConversationContext) AppendHistory(messages ...*schema.Message) {
|
||||
if c == nil || len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
c.History = append(c.History, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// HistorySnapshot 返回历史消息的浅拷贝切片。
|
||||
func (c *ConversationContext) HistorySnapshot() []*schema.Message {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneMessageSlice(c.History)
|
||||
}
|
||||
|
||||
// UpsertPinnedBlock 按 Key 写入或覆盖一段置顶上下文。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. Key 为空时直接忽略,因为后续无法做稳定覆盖;
|
||||
// 2. 若已存在同 Key block,则原位覆盖,保证"当前 plan / 当前步骤"这类上下文始终只有一份;
|
||||
// 3. 若不存在,则追加到末尾,至于渲染顺序由 prompt 层统一决定;
|
||||
// 4. 此处不自动裁剪旧内容,避免 model 层擅自丢信息。
|
||||
func (c *ConversationContext) UpsertPinnedBlock(block ContextBlock) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(block.Key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
block.Key = key
|
||||
block.Title = strings.TrimSpace(block.Title)
|
||||
block.Content = strings.TrimSpace(block.Content)
|
||||
|
||||
for i := range c.PinnedBlocks {
|
||||
if c.PinnedBlocks[i].Key == key {
|
||||
c.PinnedBlocks[i] = block
|
||||
return
|
||||
}
|
||||
}
|
||||
c.PinnedBlocks = append(c.PinnedBlocks, block)
|
||||
}
|
||||
|
||||
// RemovePinnedBlock 删除指定 Key 的置顶上下文。
|
||||
func (c *ConversationContext) RemovePinnedBlock(key string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range c.PinnedBlocks {
|
||||
if c.PinnedBlocks[i].Key != key {
|
||||
continue
|
||||
}
|
||||
c.PinnedBlocks = append(c.PinnedBlocks[:i], c.PinnedBlocks[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PinnedBlockByKey 按 Key 读取指定的置顶上下文。
|
||||
func (c *ConversationContext) PinnedBlockByKey(key string) (ContextBlock, bool) {
|
||||
if c == nil {
|
||||
return ContextBlock{}, false
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ContextBlock{}, false
|
||||
}
|
||||
|
||||
for i := range c.PinnedBlocks {
|
||||
if c.PinnedBlocks[i].Key == key {
|
||||
return c.PinnedBlocks[i], true
|
||||
}
|
||||
}
|
||||
return ContextBlock{}, false
|
||||
}
|
||||
|
||||
// PinnedBlocksSnapshot 返回置顶上下文块的浅拷贝切片。
|
||||
func (c *ConversationContext) PinnedBlocksSnapshot() []ContextBlock {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]ContextBlock, len(c.PinnedBlocks))
|
||||
copy(result, c.PinnedBlocks)
|
||||
return result
|
||||
}
|
||||
|
||||
// SetToolSchemas 整体替换工具 schema 摘要。
|
||||
func (c *ConversationContext) SetToolSchemas(schemas []ToolSchemaContext) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.ToolSchemas = cloneToolSchemaSlice(schemas)
|
||||
}
|
||||
|
||||
// ToolSchemasSnapshot 返回工具 schema 摘要的浅拷贝切片。
|
||||
func (c *ConversationContext) ToolSchemasSnapshot() []ToolSchemaContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneToolSchemaSlice(c.ToolSchemas)
|
||||
}
|
||||
|
||||
func cloneMessageSlice(messages []*schema.Message) []*schema.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*schema.Message, len(messages))
|
||||
copy(result, messages)
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneToolSchemaSlice(schemas []ToolSchemaContext) []ToolSchemaContext {
|
||||
if len(schemas) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]ToolSchemaContext, len(schemas))
|
||||
copy(result, schemas)
|
||||
return result
|
||||
}
|
||||
489
backend/services/agent/model/execute_contract.go
Normal file
489
backend/services/agent/model/execute_contract.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExecuteAction 表示 execute 阶段单轮决策的动作类型。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. LLM 只负责“申报本轮想做什么”,不直接推进状态;
|
||||
// 2. 后端只围绕这些有限动作做流程校验、证据校验、安全校验;
|
||||
// 3. 动作枚举保持收敛,避免 execute 节点后续再次长成“自由文本协议”。
|
||||
type ExecuteAction string
|
||||
|
||||
const (
|
||||
// ExecuteActionContinue 表示当前步骤尚未完成,需要继续本步骤的 ReAct 循环。
|
||||
ExecuteActionContinue ExecuteAction = "continue"
|
||||
|
||||
// ExecuteActionAskUser 表示当前步骤缺少外部信息,需要中断并追问用户。
|
||||
ExecuteActionAskUser ExecuteAction = "ask_user"
|
||||
|
||||
// ExecuteActionConfirm 表示当前步骤准备执行写操作,但必须先进入确认闸门。
|
||||
ExecuteActionConfirm ExecuteAction = "confirm"
|
||||
|
||||
// ExecuteActionNextPlan 表示当前步骤已完成,可以推进到下一个 plan 步骤。
|
||||
ExecuteActionNextPlan ExecuteAction = "next_plan"
|
||||
|
||||
// ExecuteActionDone 表示整个任务已完成,可以进入最终交付。
|
||||
ExecuteActionDone ExecuteAction = "done"
|
||||
|
||||
// ExecuteActionAbort 表示本轮流程应立即终止,并进入 deliver 做正式收口。
|
||||
ExecuteActionAbort ExecuteAction = "abort"
|
||||
)
|
||||
|
||||
// ExecuteDecision 是 execute prompt 单轮产出的统一决策结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Speak 是这轮先对用户说的话,适合在真正调工具前流式吐给前端;
|
||||
// 2. Action 是模型申报的“下一步动作类型”;
|
||||
// 3. Reason 是给后端和日志看的简短解释,不直接等价于完成证明;
|
||||
// 4. ToolCall 只是“意图”,不代表工具已经真正执行成功。
|
||||
type ExecuteDecision struct {
|
||||
Speak string `json:"speak,omitempty"`
|
||||
Action ExecuteAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
GoalCheck string `json:"goal_check,omitempty"`
|
||||
ToolCall *ToolCallIntent `json:"tool_call,omitempty"`
|
||||
Abort *AbortIntent `json:"abort,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容执行决策里几种模型高频跑偏但语义可恢复的写法。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把“空字符串占位字段”归一化成未填写,避免 json 反序列化阶段直接失败;
|
||||
// 2. 负责把 tool_call / abort 交给各自的兼容解析逻辑,尽量保留可恢复的信息;
|
||||
// 3. 不负责业务合法性校验;action 与字段互斥关系仍交给 Validate 判定。
|
||||
func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
|
||||
type rawExecuteDecision struct {
|
||||
Speak string `json:"speak,omitempty"`
|
||||
Action ExecuteAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
GoalCheck json.RawMessage `json:"goal_check,omitempty"`
|
||||
ToolCall json.RawMessage `json:"tool_call,omitempty"`
|
||||
Abort json.RawMessage `json:"abort,omitempty"`
|
||||
}
|
||||
|
||||
var raw rawExecuteDecision
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.Speak = raw.Speak
|
||||
d.Action = raw.Action
|
||||
d.Reason = raw.Reason
|
||||
goalCheck, err := decodeGoalCheckText(raw.GoalCheck)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goal_check 解析失败: %w", err)
|
||||
}
|
||||
d.GoalCheck = goalCheck
|
||||
|
||||
toolCall, err := decodeOptionalJSONObject[ToolCallIntent](raw.ToolCall)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tool_call 解析失败: %w", err)
|
||||
}
|
||||
d.ToolCall = toolCall
|
||||
|
||||
abortIntent, err := decodeOptionalJSONObject[AbortIntent](raw.Abort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abort 解析失败: %w", err)
|
||||
}
|
||||
d.Abort = abortIntent
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeGoalCheckText 兼容 goal_check 的字符串/对象写法,统一降级为字符串。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 字符串:直接使用,保持主协议不变;
|
||||
// 2. 对象:按 done_when/evidence 提取并拼接为单行证据文本;
|
||||
// 3. 数组或其他标量:尽量转成可读字符串,避免仅因格式漂移导致整轮失败。
|
||||
func decodeGoalCheckText(raw json.RawMessage) (string, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 1. 标准写法:goal_check 为字符串。
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
// 2. 兼容写法:goal_check 被模型写成对象。
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return compactGoalCheckObject(obj), nil
|
||||
}
|
||||
|
||||
// 3. 兜底:数组/标量场景,尽量保留可读信息。
|
||||
var generic any
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(formatGoalCheckValue(generic)), nil
|
||||
}
|
||||
|
||||
// compactGoalCheckObject 将对象型 goal_check 压缩为可读单行文本,优先提取 done_when/evidence。
|
||||
func compactGoalCheckObject(obj map[string]any) string {
|
||||
if len(obj) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
doneWhen := strings.TrimSpace(formatGoalCheckValue(obj["done_when"]))
|
||||
evidence := strings.TrimSpace(formatGoalCheckValue(obj["evidence"]))
|
||||
|
||||
parts := make([]string, 0, 2)
|
||||
if doneWhen != "" {
|
||||
parts = append(parts, "已满足 done_when:"+doneWhen)
|
||||
}
|
||||
if evidence != "" {
|
||||
parts = append(parts, "证据:"+evidence)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// done_when/evidence 缺失时,按 key 排序拼接,保证日志稳定可读。
|
||||
keys := make([]string, 0, len(obj))
|
||||
for key := range obj {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
fallback := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(obj[key]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
fallback = append(fallback, key+"="+text)
|
||||
}
|
||||
return strings.Join(fallback, ";")
|
||||
}
|
||||
|
||||
// formatGoalCheckValue 将任意值转成单行可读文本,用于 goal_check 压缩拼接。
|
||||
func formatGoalCheckValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case bool:
|
||||
if typed {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case []any:
|
||||
parts := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(item))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, text)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(typed[key]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, key+"="+text)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", typed))
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 execute 决策中的字符串字段。
|
||||
func (d *ExecuteDecision) Normalize() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.Speak = strings.TrimSpace(d.Speak)
|
||||
d.Action = ExecuteAction(strings.TrimSpace(string(d.Action)))
|
||||
d.Reason = strings.TrimSpace(d.Reason)
|
||||
d.GoalCheck = strings.TrimSpace(d.GoalCheck)
|
||||
if d.ToolCall != nil {
|
||||
d.ToolCall.Normalize()
|
||||
}
|
||||
if d.Abort != nil {
|
||||
d.Abort.Normalize()
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验 execute 决策的最小合法性。
|
||||
//
|
||||
// 校验原则:
|
||||
// 1. 这里只校验“协议是否自洽”,不校验工具是否真实存在,也不校验当前步骤是否真的完成;
|
||||
// 2. 只允许少量动作与 tool_call 共存,避免后续 node 层收到含糊决策;
|
||||
// 3. 真正的三类最小校验应放在执行层,这里只做第一道轻量门禁。
|
||||
func (d *ExecuteDecision) Validate() error {
|
||||
if d == nil {
|
||||
return fmt.Errorf("execute decision 不能为空")
|
||||
}
|
||||
|
||||
d.Normalize()
|
||||
if d.Action == "" {
|
||||
return fmt.Errorf("execute decision.action 不能为空")
|
||||
}
|
||||
|
||||
switch d.Action {
|
||||
case ExecuteActionContinue:
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("continue 动作不应携带 abort")
|
||||
}
|
||||
if d.ToolCall != nil {
|
||||
return d.ToolCall.Validate()
|
||||
}
|
||||
return nil
|
||||
case ExecuteActionAskUser:
|
||||
if d.ToolCall != nil {
|
||||
return fmt.Errorf("ask_user 动作不应携带 tool_call")
|
||||
}
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("ask_user 动作不应携带 abort")
|
||||
}
|
||||
return nil
|
||||
case ExecuteActionConfirm:
|
||||
if d.ToolCall == nil {
|
||||
return fmt.Errorf("confirm 动作必须携带待确认的 tool_call")
|
||||
}
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("confirm 动作不应同时携带 abort")
|
||||
}
|
||||
return d.ToolCall.Validate()
|
||||
case ExecuteActionNextPlan, ExecuteActionDone:
|
||||
if d.ToolCall != nil {
|
||||
return fmt.Errorf("%s 动作不应携带 tool_call", d.Action)
|
||||
}
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("%s 动作不应携带 abort", d.Action)
|
||||
}
|
||||
return nil
|
||||
case ExecuteActionAbort:
|
||||
if d.ToolCall != nil {
|
||||
return fmt.Errorf("abort 动作不应携带 tool_call")
|
||||
}
|
||||
if d.Abort == nil {
|
||||
return fmt.Errorf("abort 动作必须携带 abort 字段")
|
||||
}
|
||||
return d.Abort.Validate()
|
||||
default:
|
||||
return fmt.Errorf("未知 execute action: %s", d.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// AbortIntent 表示 execute 阶段声明的正式终止意图。
|
||||
//
|
||||
// 说明:
|
||||
// 1. code 是稳定机器码,便于后续前端/埋点识别终止类型;
|
||||
// 2. user_message 是最终给用户看的收口文案;
|
||||
// 3. internal_reason 只用于日志排查,允许更技术化。
|
||||
type AbortIntent struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
UserMessage string `json:"user_message"`
|
||||
InternalReason string `json:"internal_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 清洗终止意图中的稳定字段。
|
||||
func (a *AbortIntent) Normalize() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.Code = strings.TrimSpace(a.Code)
|
||||
a.UserMessage = strings.TrimSpace(a.UserMessage)
|
||||
a.InternalReason = strings.TrimSpace(a.InternalReason)
|
||||
}
|
||||
|
||||
// Validate 校验终止意图的最小可用性。
|
||||
func (a *AbortIntent) Validate() error {
|
||||
if a == nil {
|
||||
return fmt.Errorf("abort 不能为空")
|
||||
}
|
||||
a.Normalize()
|
||||
if a.UserMessage == "" {
|
||||
return fmt.Errorf("abort.user_message 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToolCallIntent 表示 execute 阶段申报的工具调用意图。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 这里只描述“模型想调用什么工具、传什么参数”,不代表调用已经发生;
|
||||
// 2. Arguments 暂时保留 map 结构,方便 prompt 输出原生 JSON 对象;
|
||||
// 3. 是否需要 confirm 不应由模型决定,后续应由工具注册表或后端策略判定。
|
||||
type ToolCallIntent struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容 tool_call 里“arguments / parameters”两种高频字段名。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先使用标准字段 arguments,保持当前正式协议不变;
|
||||
// 2. 仅当 arguments 缺失时,回退复用 parameters,兼容模型历史习惯;
|
||||
// 3. 不负责校验参数是否满足具体工具 schema,后续仍由工具层负责。
|
||||
func (t *ToolCallIntent) UnmarshalJSON(data []byte) error {
|
||||
type rawToolCallIntent struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
var raw rawToolCallIntent
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Name = raw.Name
|
||||
t.Arguments = raw.Arguments
|
||||
if len(t.Arguments) == 0 && len(raw.Parameters) > 0 {
|
||||
t.Arguments = raw.Parameters
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 清洗工具调用意图中的稳定字段。
|
||||
func (t *ToolCallIntent) Normalize() {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.Name = strings.TrimSpace(t.Name)
|
||||
}
|
||||
|
||||
// Validate 校验工具调用意图的最小合法性。
|
||||
func (t *ToolCallIntent) Validate() error {
|
||||
if t == nil {
|
||||
return fmt.Errorf("tool_call 不能为空")
|
||||
}
|
||||
t.Normalize()
|
||||
if t.Name == "" {
|
||||
return fmt.Errorf("tool_call.name 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeOptionalJSONObject 统一兼容“可选对象字段被模型写成空字符串”的情况。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 字段缺失、null、空字符串都视为“未填写”,返回 nil;
|
||||
// 2. 只有在确实出现对象内容时,才继续反序列化为目标结构;
|
||||
// 3. 若模型传入了非空字符串等不可恢复内容,显式报错,避免把脏数据静默吞掉。
|
||||
func decodeOptionalJSONObject[T any](raw json.RawMessage) (*T, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("期望对象,实际收到非空字符串")
|
||||
}
|
||||
|
||||
var out T
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ExecuteEvidenceSource 表示“当前步骤完成证明”来自哪里。
|
||||
type ExecuteEvidenceSource string
|
||||
|
||||
const (
|
||||
// ExecuteEvidenceSourceToolObservation 表示来自读工具或分析工具的真实 observation。
|
||||
ExecuteEvidenceSourceToolObservation ExecuteEvidenceSource = "tool_observation"
|
||||
|
||||
// ExecuteEvidenceSourceWriteReceipt 表示来自写工具成功执行后的回执。
|
||||
ExecuteEvidenceSourceWriteReceipt ExecuteEvidenceSource = "write_receipt"
|
||||
|
||||
// ExecuteEvidenceSourceUserReply 表示来自用户补充回答的外部事实。
|
||||
ExecuteEvidenceSourceUserReply ExecuteEvidenceSource = "user_reply"
|
||||
)
|
||||
|
||||
// ExecuteEvidenceReceipt 表示“一条可被后端认可的最小事实证据”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. StepIndex 用来绑定这条证据属于哪个 plan 步骤,避免旧 observation 污染新步骤;
|
||||
// 2. Source / Name / Success 描述“这条证据是怎么来的、是否真的发生了”;
|
||||
// 3. Summary 只用于日志、调试和交付串联,不替代原始 observation 本身;
|
||||
// 4. 这里不做语义推理,只负责记录事实。
|
||||
type ExecuteEvidenceReceipt struct {
|
||||
StepIndex int `json:"step_index"`
|
||||
Source ExecuteEvidenceSource `json:"source"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ArgumentsDigest string `json:"arguments_digest,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 清洗证据回执中的稳定字段。
|
||||
func (r *ExecuteEvidenceReceipt) Normalize() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.Source = ExecuteEvidenceSource(strings.TrimSpace(string(r.Source)))
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.ArgumentsDigest = strings.TrimSpace(r.ArgumentsDigest)
|
||||
r.Summary = strings.TrimSpace(r.Summary)
|
||||
}
|
||||
|
||||
// Validate 校验证据回执是否具备最小可用信息。
|
||||
func (r *ExecuteEvidenceReceipt) Validate() error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("evidence receipt 不能为空")
|
||||
}
|
||||
|
||||
r.Normalize()
|
||||
if r.StepIndex < 0 {
|
||||
return fmt.Errorf("evidence receipt.step_index 不能小于 0")
|
||||
}
|
||||
switch r.Source {
|
||||
case ExecuteEvidenceSourceToolObservation, ExecuteEvidenceSourceWriteReceipt, ExecuteEvidenceSourceUserReply:
|
||||
default:
|
||||
return fmt.Errorf("未知 evidence source: %s", r.Source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteValidationResult 保存 execute 单轮的三类最小校验结果。
|
||||
//
|
||||
// 三类校验语义:
|
||||
// 1. FlowPassed:当前动作在流程上是否合法,例如 done 是否允许直接发生;
|
||||
// 2. EvidencePassed:当前动作是否有最小事实证据支撑;
|
||||
// 3. SafetyPassed:当前动作是否触发了安全兜底,例如超轮次、重复空转、待确认未完成。
|
||||
type ExecuteValidationResult struct {
|
||||
FlowPassed bool `json:"flow_passed"`
|
||||
FlowReason string `json:"flow_reason,omitempty"`
|
||||
EvidencePassed bool `json:"evidence_passed"`
|
||||
EvidenceReason string `json:"evidence_reason,omitempty"`
|
||||
SafetyPassed bool `json:"safety_passed"`
|
||||
SafetyReason string `json:"safety_reason,omitempty"`
|
||||
}
|
||||
337
backend/services/agent/model/graph_run_state.go
Normal file
337
backend/services/agent/model/graph_run_state.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// AgentGraphRequest 描述一次 agent graph 运行的请求级输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只放"当前这次请求"天然携带的轻量数据,例如用户本轮输入;
|
||||
// 2. 不负责承载可持久化流程状态,流程状态仍归 AgentRuntimeState;
|
||||
// 3. 不负责承载 LLM / emitter / store 等依赖,这些统一放进 AgentGraphDeps。
|
||||
type AgentGraphRequest struct {
|
||||
UserInput string
|
||||
ConfirmAction string // "accept" / "reject" / "",仅 confirm 恢复场景由前端传入
|
||||
// ResumeInteractionID 用于校验“本次恢复请求”是否命中了当前 pending 交互,避免旧卡片误恢复。
|
||||
ResumeInteractionID string
|
||||
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行,适合前端已展示预览、用户无需逐步确认的场景
|
||||
}
|
||||
|
||||
// Normalize 统一清洗请求级输入中的字符串字段。
|
||||
func (r *AgentGraphRequest) Normalize() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.UserInput = strings.TrimSpace(r.UserInput)
|
||||
r.ConfirmAction = strings.TrimSpace(r.ConfirmAction)
|
||||
r.ResumeInteractionID = strings.TrimSpace(r.ResumeInteractionID)
|
||||
}
|
||||
|
||||
// RoughBuildPlacement 是粗排算法返回的单条放置结果。
|
||||
// 字段使用 DB 坐标系(week/dayOfWeek/section),由 RoughBuild 节点转换为 ScheduleState 的 day_index。
|
||||
type RoughBuildPlacement struct {
|
||||
TaskItemID int
|
||||
Week int
|
||||
DayOfWeek int
|
||||
SectionFrom int
|
||||
SectionTo int
|
||||
}
|
||||
|
||||
// RoughBuildFunc 是粗排算法的依赖注入签名。
|
||||
// 由 service 层封装 HybridScheduleWithPlanMulti 后注入,agent 层不直接依赖外层 model。
|
||||
type RoughBuildFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]RoughBuildPlacement, error)
|
||||
|
||||
// WriteSchedulePreviewFunc 是排程预览写入的依赖注入签名。
|
||||
// 由 service 层封装 cacheDAO 后注入,execute/deliver 节点可按需调用:
|
||||
// 1. execute 写工具后可实时刷新,保障前端及时看到最新调整;
|
||||
// 2. deliver 结束时再做最终覆盖写,保障收口状态一致。
|
||||
type WriteSchedulePreviewFunc func(ctx context.Context, state *schedule.ScheduleState, userID int, conversationID string, taskClassIDs []int) error
|
||||
|
||||
// PersistVisibleMessageFunc 是 agent 主循环逐条持久化可见消息的回调签名。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理真正对用户可见的 assistant speak,不处理工具结果或内部纠错提示;
|
||||
// 2. 由节点在 AppendHistory 之后主动调用,让上层同步把这条消息写入 Redis + MySQL;
|
||||
// 3. 执行方可以做无损降级(例如 Redis 写失败只记日志),但应返回 error 便于上层记录。
|
||||
type PersistVisibleMessageFunc func(ctx context.Context, state *CommonState, msg *schema.Message) error
|
||||
|
||||
// AgentGraphDeps 描述 graph/node 层运行时真正依赖的可插拔能力。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 让 graph 不再只拿到"裸状态",而是能拿到上下文、模型和输出能力;
|
||||
// 2. Chat/Plan/Execute/Deliver 允许分别挂不同 client,但也允许先复用同一个 client;
|
||||
// 3. ChunkEmitter 统一承接阶段提示、正文、工具事件、确认请求等 SSE 输出。
|
||||
type AgentGraphDeps struct {
|
||||
ChatClient *llmservice.Client
|
||||
PlanClient *llmservice.Client
|
||||
ExecuteClient *llmservice.Client
|
||||
DeliverClient *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
StateStore AgentStateStore
|
||||
ToolRegistry *agenttools.ToolRegistry
|
||||
ScheduleProvider ScheduleStateProvider // 按 DAO 注入,Execute 节点按需加载 ScheduleState
|
||||
CompactionStore CompactionStore // 按 DAO 注入,用于 Execute 上下文压缩持久化
|
||||
RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口
|
||||
WriteSchedulePreview WriteSchedulePreviewFunc // 按 Service 注入,排程预览写入入口
|
||||
|
||||
// thinking 开关:由 config.yaml 的 agent.thinking 段注入,各节点按需读取。
|
||||
ThinkingPlan bool
|
||||
ThinkingExecute bool
|
||||
ThinkingDeliver bool
|
||||
|
||||
// 记忆预取管线:由 service 层启动的后台检索 goroutine 写入。
|
||||
// channel 携带已渲染的文本内容(非原始 ItemDTO),节点直接写入 pinned block。
|
||||
MemoryFuture chan string // buffered(1),携带 renderMemoryPinnedContentByMode 的输出
|
||||
MemoryConsumed bool // 保证 channel 只读一次,后续 Execute ReAct 循环跳过等待
|
||||
|
||||
// PersistVisibleMessage 按 Service 注入,agent 每个节点产出的可见 speak
|
||||
// 都会在 AppendHistory 之后立刻调用这个回调,把消息同步落到 Redis + MySQL。
|
||||
PersistVisibleMessage PersistVisibleMessageFunc
|
||||
|
||||
// QuickTaskDeps 快捷任务节点的直接依赖,绕过 ToolRegistry 走轻量路径。
|
||||
QuickTaskDeps QuickTaskDeps
|
||||
}
|
||||
|
||||
// QuickTaskDeps 描述快捷任务节点所需的服务层依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. QuickTask 节点直接调这些函数,不经过 ToolRegistry,不走 ReAct 循环;
|
||||
// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链。
|
||||
type QuickTaskDeps struct {
|
||||
// CreateTask 创建一条四象限任务,返回 task_id。
|
||||
CreateTask func(userID int, title string, priorityGroup int, estimatedSections int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error)
|
||||
// QueryTasks 按条件查询用户任务列表。
|
||||
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
|
||||
}
|
||||
|
||||
// --- 记忆 pinned block 常量(供 agent/sv 和 node 层共享) ---
|
||||
|
||||
const (
|
||||
// MemoryContextBlockKey 记忆上下文在 ConversationContext PinnedBlock 中的唯一 key。
|
||||
MemoryContextBlockKey = "memory_context"
|
||||
// MemoryContextBlockTitle 记忆上下文 pinned block 的标题,用于 prompt 渲染。
|
||||
MemoryContextBlockTitle = "相关记忆"
|
||||
// MemoryFreshTimeout 是 Execute/Plan 节点等待后台记忆检索完成的最大时长。
|
||||
MemoryFreshTimeout = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// EnsureChunkEmitter 保证 graph 运行时始终有一个可用的 chunk 发射器。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 依赖为空时回退到 Noop emitter,避免骨架期因为没接前端而到处判空;
|
||||
// 2. 这里只兜底"能安全调用",不负责填充真实 request_id / model_name;
|
||||
// 3. 后续 service 层一旦接上真实 emitter,会自然覆盖这里的空实现。
|
||||
func (d *AgentGraphDeps) EnsureChunkEmitter() *agentstream.ChunkEmitter {
|
||||
if d == nil {
|
||||
return agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", 0)
|
||||
}
|
||||
if d.ChunkEmitter == nil {
|
||||
d.ChunkEmitter = agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", 0)
|
||||
}
|
||||
return d.ChunkEmitter
|
||||
}
|
||||
|
||||
// ResolveChatClient 返回 chat 阶段可用的模型客户端。
|
||||
func (d *AgentGraphDeps) ResolveChatClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// ResolvePlanClient 返回 planning 阶段可用的模型客户端。
|
||||
//
|
||||
// 兜底策略:
|
||||
// 1. 优先使用显式注入的 PlanClient;
|
||||
// 2. 若未单独注入,则回退到 ChatClient;
|
||||
// 3. 这样在骨架期可先用一套 client 跑通,再按需拆分 strategist / worker。
|
||||
func (d *AgentGraphDeps) ResolvePlanClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.PlanClient != nil {
|
||||
return d.PlanClient
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// ResolveExecuteClient 返回 execute 阶段可用的模型客户端。
|
||||
func (d *AgentGraphDeps) ResolveExecuteClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.ExecuteClient != nil {
|
||||
return d.ExecuteClient
|
||||
}
|
||||
if d.PlanClient != nil {
|
||||
return d.PlanClient
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// ResolveDeliverClient 返回 deliver 阶段可用的模型客户端。
|
||||
func (d *AgentGraphDeps) ResolveDeliverClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.DeliverClient != nil {
|
||||
return d.DeliverClient
|
||||
}
|
||||
if d.ExecuteClient != nil {
|
||||
return d.ExecuteClient
|
||||
}
|
||||
if d.PlanClient != nil {
|
||||
return d.PlanClient
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// AgentGraphRunInput 是执行 agent 通用 graph 所需的完整入口参数。
|
||||
//
|
||||
// 字段说明:
|
||||
// 1. RuntimeState:可持久化流程状态与 pending interaction;
|
||||
// 2. ConversationContext:本轮喂给模型的上下文材料;
|
||||
// 3. Request:当前这次请求的轻量输入;
|
||||
// 4. Deps:graph/node 层真正依赖的可插拔能力。
|
||||
type AgentGraphRunInput struct {
|
||||
RuntimeState *AgentRuntimeState
|
||||
ConversationContext *ConversationContext
|
||||
ScheduleState *schedule.ScheduleState
|
||||
OriginalScheduleState *schedule.ScheduleState
|
||||
Request AgentGraphRequest
|
||||
Deps AgentGraphDeps
|
||||
}
|
||||
|
||||
// AgentGraphState 是 graph 内部真正流转的运行态容器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把"流程状态 + 对话上下文 + 请求输入 + 运行依赖"收口到同一个对象;
|
||||
// 2. 负责给 graph 分支和 node 提供最小必要的兜底访问方法;
|
||||
// 3. 不负责持久化,不负责真正业务执行。
|
||||
type AgentGraphState struct {
|
||||
RuntimeState *AgentRuntimeState
|
||||
ConversationContext *ConversationContext
|
||||
Request AgentGraphRequest
|
||||
Deps AgentGraphDeps
|
||||
ScheduleState *schedule.ScheduleState // 工具操作的内存数据源,Execute 节点按需加载
|
||||
OriginalScheduleState *schedule.ScheduleState // 首次加载时的原始快照,供 diff 用
|
||||
}
|
||||
|
||||
// NewAgentGraphState 把入口参数整理成 graph 内部状态。
|
||||
func NewAgentGraphState(input AgentGraphRunInput) *AgentGraphState {
|
||||
st := &AgentGraphState{
|
||||
RuntimeState: input.RuntimeState,
|
||||
ConversationContext: input.ConversationContext,
|
||||
Request: input.Request,
|
||||
Deps: input.Deps,
|
||||
ScheduleState: input.ScheduleState,
|
||||
OriginalScheduleState: input.OriginalScheduleState,
|
||||
}
|
||||
st.Request.Normalize()
|
||||
st.EnsureRuntimeState()
|
||||
st.EnsureConversationContext()
|
||||
st.Deps.EnsureChunkEmitter()
|
||||
return st
|
||||
}
|
||||
|
||||
// EnsureRuntimeState 保证 graph 内部始终持有一份可用的运行态。
|
||||
func (s *AgentGraphState) EnsureRuntimeState() *AgentRuntimeState {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.RuntimeState == nil {
|
||||
s.RuntimeState = NewAgentRuntimeState(nil)
|
||||
}
|
||||
s.RuntimeState.EnsureCommonState()
|
||||
return s.RuntimeState
|
||||
}
|
||||
|
||||
// EnsureFlowState 返回可持久化的主流程状态。
|
||||
func (s *AgentGraphState) EnsureFlowState() *CommonState {
|
||||
runtimeState := s.EnsureRuntimeState()
|
||||
if runtimeState == nil {
|
||||
return nil
|
||||
}
|
||||
return runtimeState.EnsureCommonState()
|
||||
}
|
||||
|
||||
// EnsureConversationContext 保证 graph 内部始终持有一份可用的会话上下文。
|
||||
func (s *AgentGraphState) EnsureConversationContext() *ConversationContext {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.ConversationContext == nil {
|
||||
s.ConversationContext = NewConversationContext("")
|
||||
}
|
||||
return s.ConversationContext
|
||||
}
|
||||
|
||||
// EnsureChunkEmitter 返回 graph 可安全调用的 chunk 发射器。
|
||||
func (s *AgentGraphState) EnsureChunkEmitter() *agentstream.ChunkEmitter {
|
||||
if s == nil {
|
||||
return agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", 0)
|
||||
}
|
||||
return s.Deps.EnsureChunkEmitter()
|
||||
}
|
||||
|
||||
// ResolveToolRegistry 返回可用的工具注册表。
|
||||
func (s *AgentGraphState) ResolveToolRegistry() *agenttools.ToolRegistry {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Deps.ToolRegistry
|
||||
}
|
||||
|
||||
// EnsureScheduleState 确保 ScheduleState 已加载。
|
||||
// 首次调用时通过 ScheduleProvider 从 DB 加载,后续复用内存中的 state。
|
||||
func (s *AgentGraphState) EnsureScheduleState(ctx context.Context) (*schedule.ScheduleState, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
flowState := s.EnsureFlowState()
|
||||
if s.ScheduleState != nil {
|
||||
if s.OriginalScheduleState == nil {
|
||||
// 1. 兼容老快照:历史 Redis 快照里可能还没带 original_state。
|
||||
// 2. 当前阶段虽然已经不落库,但后续若重新接回 diff 链,仍需要稳定的原始快照。
|
||||
// 3. 因此这里在"已恢复出 ScheduleState、但缺 original"时补一份克隆兜底。
|
||||
s.OriginalScheduleState = s.ScheduleState.Clone()
|
||||
}
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.ScheduleState, flowState.TaskClassIDs)
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.OriginalScheduleState, flowState.TaskClassIDs)
|
||||
return s.ScheduleState, nil
|
||||
}
|
||||
if s.Deps.ScheduleProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
userID := flowState.UserID
|
||||
var (
|
||||
state *schedule.ScheduleState
|
||||
err error
|
||||
)
|
||||
// 1. 若 provider 支持按 task_class_ids 精确加载,则优先走 scoped 入口。
|
||||
// 2. 这样可以让 DayMapping 与粗排算法使用同一批任务类窗口,避免"全量任务类脏日期污染本轮窗口"。
|
||||
// 3. 若当前实现尚未支持 scoped 加载,则回退到旧入口,并继续复用后面的 scope 裁剪。
|
||||
if scopedProvider, ok := s.Deps.ScheduleProvider.(ScopedScheduleStateProvider); ok && len(flowState.TaskClassIDs) > 0 {
|
||||
state, err = scopedProvider.LoadScheduleStateForTaskClasses(ctx, userID, flowState.TaskClassIDs)
|
||||
} else {
|
||||
state, err = s.Deps.ScheduleProvider.LoadScheduleState(ctx, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ScheduleState = state
|
||||
// 保存原始快照,供后续 diff 使用。
|
||||
s.OriginalScheduleState = state.Clone()
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.ScheduleState, flowState.TaskClassIDs)
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.OriginalScheduleState, flowState.TaskClassIDs)
|
||||
return state, nil
|
||||
}
|
||||
252
backend/services/agent/model/pending_interaction.go
Normal file
252
backend/services/agent/model/pending_interaction.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// PhaseChatting 表示当前请求只需正常聊天,不进入 plan / execute 主链路。
|
||||
PhaseChatting Phase = "chatting"
|
||||
|
||||
// PhaseInterrupted 表示本轮执行被"待用户交互"显式打断,当前连接应结束并等待恢复。
|
||||
PhaseInterrupted Phase = "interrupted"
|
||||
)
|
||||
|
||||
const PendingInteractionSnapshotVersion = 1
|
||||
|
||||
const (
|
||||
// PendingMetaAskUserSpeakStreamed 表示 ask_user 文本已在上游节点流式推送过。
|
||||
// interrupt 节点据此决定是否跳过二次正文推送,避免前端出现重复气泡。
|
||||
PendingMetaAskUserSpeakStreamed = "ask_user_speak_streamed"
|
||||
// PendingMetaAskUserHistoryAppended 表示 ask_user 文本已在上游写入过 history。
|
||||
// interrupt 节点据此避免二次追加历史,防止上下文重复。
|
||||
PendingMetaAskUserHistoryAppended = "ask_user_history_appended"
|
||||
)
|
||||
|
||||
// PendingInteractionType 表示当前挂起交互的类型。
|
||||
type PendingInteractionType string
|
||||
|
||||
const (
|
||||
PendingInteractionTypeAskUser PendingInteractionType = "ask_user"
|
||||
PendingInteractionTypeConfirm PendingInteractionType = "confirm"
|
||||
PendingInteractionTypeConnectionLost PendingInteractionType = "connection_lost"
|
||||
)
|
||||
|
||||
// PendingInteractionStatus 表示挂起交互的生命周期状态。
|
||||
type PendingInteractionStatus string
|
||||
|
||||
const (
|
||||
PendingInteractionStatusOpen PendingInteractionStatus = "open"
|
||||
PendingInteractionStatusResolved PendingInteractionStatus = "resolved"
|
||||
PendingInteractionStatusCanceled PendingInteractionStatus = "canceled"
|
||||
)
|
||||
|
||||
// PendingToolCallSnapshot 保存"待确认工具调用"的最小快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责保存真正落库 / 落缓存恢复执行所需的最小信息;
|
||||
// 2. ArgsJSON 约定存已经序列化好的参数快照,避免此处反向依赖具体 tool 参数结构;
|
||||
// 3. 不负责工具执行,不负责幂等校验,不负责回滚。
|
||||
type PendingToolCallSnapshot struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ArgsJSON string `json:"args_json"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// PendingInteraction 保存"本轮需要中断并等待用户后续动作"的交互快照。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. ask_user 与 confirm 都不是业务 tool,而是流程级中断,所以单独建模;
|
||||
// 2. ResumeNode / ResumePhase / ResumeStep 用来记录恢复点,避免用户回答后整条链路从头乱跑;
|
||||
// 3. 该结构设计成可被 Redis + MySQL 直接存储的快照骨架,后续只需要补序列化与持久化接线。
|
||||
//
|
||||
// TODO(agent/api): 后续由"用户追问回复接口 / 确认回调接口"读取这份快照并恢复运行。
|
||||
type PendingInteraction struct {
|
||||
Version int `json:"version"`
|
||||
InteractionID string `json:"interaction_id"`
|
||||
Type PendingInteractionType `json:"type"`
|
||||
Status PendingInteractionStatus `json:"status"`
|
||||
DisplayText string `json:"display_text"`
|
||||
ResumeNode string `json:"resume_node"`
|
||||
ResumePhase Phase `json:"resume_phase"`
|
||||
ResumeStep int `json:"resume_step"`
|
||||
PendingTool *PendingToolCallSnapshot `json:"pending_tool,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// AgentRuntimeState 是 graph 运行时真正流转的状态容器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. CommonState 继续只负责主流程控制;
|
||||
// 2. PendingInteraction 负责承载"需要中断后恢复"的交互快照;
|
||||
// 3. 这样既不污染 CommonState 的职责,又能让 graph 在一次入参里拿到完整运行态。
|
||||
type AgentRuntimeState struct {
|
||||
*CommonState `json:"common_state"`
|
||||
// PendingInteraction 承载挂起交互的持久化快照。
|
||||
PendingInteraction *PendingInteraction `json:"pending_interaction,omitempty"`
|
||||
// PendingConfirmTool 是 Execute → Confirm 之间传递待确认工具信息的临时邮箱。
|
||||
// Execute 节点写入,Confirm 节点读出并清空,不参与持久化。
|
||||
PendingConfirmTool *PendingToolCallSnapshot `json:"-"`
|
||||
}
|
||||
|
||||
// NewAgentRuntimeState 创建 graph 运行态。
|
||||
func NewAgentRuntimeState(state *CommonState) *AgentRuntimeState {
|
||||
rt := &AgentRuntimeState{CommonState: state}
|
||||
rt.EnsureCommonState()
|
||||
return rt
|
||||
}
|
||||
|
||||
// EnsureCommonState 保证运行态里始终有一份可用的流程状态。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 若 CommonState 为空,则补一份最小默认值,避免 graph / node 层空指针;
|
||||
// 2. 若 Phase 尚未设置,则默认回到 planning,保持当前主链路的保守起点;
|
||||
// 3. 若 MaxRounds 未设置,则回填默认值,避免编译后运行时无上限循环。
|
||||
func (s *AgentRuntimeState) EnsureCommonState() *CommonState {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.CommonState == nil {
|
||||
s.CommonState = &CommonState{}
|
||||
}
|
||||
if s.CommonState.Phase == "" {
|
||||
s.CommonState.Phase = PhasePlanning
|
||||
}
|
||||
if s.CommonState.MaxRounds <= 0 {
|
||||
s.CommonState.MaxRounds = DefaultMaxRounds
|
||||
}
|
||||
return s.CommonState
|
||||
}
|
||||
|
||||
// HasPendingInteraction 判断当前是否存在待恢复交互。
|
||||
func (s *AgentRuntimeState) HasPendingInteraction() bool {
|
||||
if s == nil || s.PendingInteraction == nil {
|
||||
return false
|
||||
}
|
||||
return s.PendingInteraction.Status == PendingInteractionStatusOpen
|
||||
}
|
||||
|
||||
// PendingInteractionType 返回当前挂起交互类型。
|
||||
func (s *AgentRuntimeState) PendingInteractionType() PendingInteractionType {
|
||||
if !s.HasPendingInteraction() {
|
||||
return ""
|
||||
}
|
||||
return s.PendingInteraction.Type
|
||||
}
|
||||
|
||||
// OpenAskUserInteraction 打开一个"向用户追问"的中断快照。
|
||||
func (s *AgentRuntimeState) OpenAskUserInteraction(interactionID, question, resumeNode string) {
|
||||
s.openPendingInteraction(
|
||||
PendingInteractionTypeAskUser,
|
||||
interactionID,
|
||||
question,
|
||||
resumeNode,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// OpenConfirmInteraction 打开一个"写操作待确认"的中断快照。
|
||||
func (s *AgentRuntimeState) OpenConfirmInteraction(interactionID, confirmText, resumeNode string, pendingTool *PendingToolCallSnapshot) {
|
||||
s.openPendingInteraction(
|
||||
PendingInteractionTypeConfirm,
|
||||
interactionID,
|
||||
confirmText,
|
||||
resumeNode,
|
||||
pendingTool,
|
||||
)
|
||||
}
|
||||
|
||||
// ResumeFromPending 从挂起交互恢复主流程。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 仅当存在 open 状态的 pending interaction 时才执行恢复;
|
||||
// 2. 恢复时回写之前快照下来的 phase / step,确保继续跑的是原任务位置而不是新分支;
|
||||
// 3. 恢复成功后清空挂起快照,避免同一份 pending 被重复消费。
|
||||
func (s *AgentRuntimeState) ResumeFromPending() bool {
|
||||
if !s.HasPendingInteraction() {
|
||||
return false
|
||||
}
|
||||
|
||||
flowState := s.EnsureCommonState()
|
||||
pending := s.PendingInteraction
|
||||
flowState.Phase = pending.ResumePhase
|
||||
flowState.CurrentStep = pending.ResumeStep
|
||||
pending.Status = PendingInteractionStatusResolved
|
||||
s.PendingInteraction = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// ClearPendingInteraction 直接清空挂起交互。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅负责粗暴清空快照;
|
||||
// 2. 不自动恢复 phase / step,避免误把"取消交互"与"恢复执行"混为一谈;
|
||||
// 3. 若需要恢复流程,应优先使用 ResumeFromPending。
|
||||
func (s *AgentRuntimeState) ClearPendingInteraction() {
|
||||
if s == nil || s.PendingInteraction == nil {
|
||||
return
|
||||
}
|
||||
s.PendingInteraction.Status = PendingInteractionStatusCanceled
|
||||
s.PendingInteraction = nil
|
||||
}
|
||||
|
||||
// SetPendingInteractionMetadata 为当前 open 状态的 pending interaction 写入元信息。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅对当前挂起交互打运行态标记,不参与业务语义判断;
|
||||
// 2. 若当前没有 pending interaction,则静默跳过;
|
||||
// 3. metadata 仅用于节点间协作(如避免 ask_user 重复推送)。
|
||||
func (s *AgentRuntimeState) SetPendingInteractionMetadata(key string, value any) {
|
||||
if s == nil || s.PendingInteraction == nil || s.PendingInteraction.Status != PendingInteractionStatusOpen {
|
||||
return
|
||||
}
|
||||
trimmedKey := strings.TrimSpace(key)
|
||||
if trimmedKey == "" {
|
||||
return
|
||||
}
|
||||
if s.PendingInteraction.Metadata == nil {
|
||||
s.PendingInteraction.Metadata = make(map[string]any)
|
||||
}
|
||||
s.PendingInteraction.Metadata[trimmedKey] = value
|
||||
}
|
||||
|
||||
func (s *AgentRuntimeState) openPendingInteraction(
|
||||
interactionType PendingInteractionType,
|
||||
interactionID string,
|
||||
displayText string,
|
||||
resumeNode string,
|
||||
pendingTool *PendingToolCallSnapshot,
|
||||
) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState := s.EnsureCommonState()
|
||||
resumePhase := flowState.Phase
|
||||
if resumePhase == "" {
|
||||
resumePhase = PhasePlanning
|
||||
}
|
||||
|
||||
s.PendingInteraction = &PendingInteraction{
|
||||
Version: PendingInteractionSnapshotVersion,
|
||||
InteractionID: strings.TrimSpace(interactionID),
|
||||
Type: interactionType,
|
||||
Status: PendingInteractionStatusOpen,
|
||||
DisplayText: strings.TrimSpace(displayText),
|
||||
ResumeNode: strings.TrimSpace(resumeNode),
|
||||
ResumePhase: resumePhase,
|
||||
ResumeStep: flowState.CurrentStep,
|
||||
PendingTool: clonePendingToolCallSnapshot(pendingTool),
|
||||
}
|
||||
|
||||
// 1. 一旦进入 pending 状态,当前连接上的 graph 应立即停止向后执行。
|
||||
// 2. 这里先统一把 Phase 置为 interrupted,后续恢复时再按快照写回原阶段。
|
||||
// 3. 这样分支函数只需要判断 HasPendingInteraction(),无需猜测"当前 phase 是否仍可信"。
|
||||
flowState.Phase = PhaseInterrupted
|
||||
}
|
||||
|
||||
func clonePendingToolCallSnapshot(snapshot *PendingToolCallSnapshot) *PendingToolCallSnapshot {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *snapshot
|
||||
return &copied
|
||||
}
|
||||
245
backend/services/agent/model/plan_contract.go
Normal file
245
backend/services/agent/model/plan_contract.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlanComplexity 表示规划阶段评估的任务复杂度。
|
||||
type PlanComplexity string
|
||||
|
||||
const (
|
||||
// PlanComplexitySimple 表示简单明确的操作,步骤之间无复杂依赖。
|
||||
PlanComplexitySimple PlanComplexity = "simple"
|
||||
|
||||
// PlanComplexityModerate 表示多步操作,需要一定推理但不涉及深度分析。
|
||||
PlanComplexityModerate PlanComplexity = "moderate"
|
||||
|
||||
// PlanComplexityComplex 表示需要深度推理、多方案比较或复杂依赖关系的任务。
|
||||
PlanComplexityComplex PlanComplexity = "complex"
|
||||
)
|
||||
|
||||
// PlanAction 表示规划阶段单轮决策的动作类型。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 规划阶段只关心“继续规划 / 追问用户 / 规划完成”这三类动作;
|
||||
// 2. 这里先不把工具调用塞进 contract,避免过早把 plan loop 复杂化;
|
||||
// 3. 规划层产出的是“自然语言计划”,不是执行层的工具动作。
|
||||
type PlanAction string
|
||||
|
||||
const (
|
||||
// PlanActionContinue 表示当前信息已足够,继续规划下一轮。
|
||||
PlanActionContinue PlanAction = "continue"
|
||||
|
||||
// PlanActionAskUser 表示当前规划缺少关键信息,需要中断并追问用户。
|
||||
PlanActionAskUser PlanAction = "ask_user"
|
||||
|
||||
// PlanActionDone 表示规划已经完成,可以进入 confirm 或下一阶段。
|
||||
PlanActionDone PlanAction = "plan_done"
|
||||
)
|
||||
|
||||
// PlanDecision 是 plan prompt 单轮产出的统一决策结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Speak 是本轮先对用户说的话;若 action=ask_user,通常这里会承载要追问的问题;
|
||||
// 2. Action 是规划阶段的下一步动作类型;
|
||||
// 3. Reason 是给后端和日志看的简短解释;
|
||||
// 4. PlanSteps 只在 plan_done 时要求返回,表示本轮最终确认下来的完整自然语言计划;
|
||||
// 5. NeedsRoughBuild 为 true 时,Confirm 后自动触发粗排节点,不需要 LLM 在 plan_steps 里手动描述放置步骤;
|
||||
// 6. TaskClassIDs 是本次粗排涉及的任务类 ID 列表,与 CommonState.TaskClassIDs 保持一致。
|
||||
type PlanDecision struct {
|
||||
Speak string `json:"speak,omitempty"`
|
||||
Action PlanAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Complexity PlanComplexity `json:"complexity"`
|
||||
PlanSteps []PlanStep `json:"plan_steps,omitempty"`
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||||
ContextHook *ContextHook `json:"context_hook,omitempty"`
|
||||
}
|
||||
|
||||
// ContextHook 表示 plan 阶段给 execute 阶段的上下文注入建议。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅承载“建议激活哪个 domain/packs”,不负责真正执行 context_tools_add/remove;
|
||||
// 2. domain 仅允许 schedule/taskclass,packs 仅允许 schedule 的可选包;
|
||||
// 3. 该结构会在 execute 首轮被消费一次,消费后由后端清空。
|
||||
type ContextHook struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗规划决策中的字符串字段。
|
||||
func (d *PlanDecision) Normalize() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.Speak = strings.TrimSpace(d.Speak)
|
||||
d.Action = PlanAction(strings.TrimSpace(string(d.Action)))
|
||||
d.Reason = strings.TrimSpace(d.Reason)
|
||||
d.Complexity = PlanComplexity(strings.TrimSpace(string(d.Complexity)))
|
||||
for i := range d.PlanSteps {
|
||||
d.PlanSteps[i].Normalize()
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
d.ContextHook.Normalize()
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验规划决策的最小合法性。
|
||||
//
|
||||
// 校验原则:
|
||||
// 1. 这里只校验“协议是否自洽”,不校验规划内容是否聪明、是否足够好;
|
||||
// 2. 只有 plan_done 允许返回完整 plan_steps;
|
||||
// 3. 真正的规划质量判断仍留给后续 node 层和用户确认环节。
|
||||
func (d *PlanDecision) Validate() error {
|
||||
if d == nil {
|
||||
return fmt.Errorf("plan decision 不能为空")
|
||||
}
|
||||
|
||||
d.Normalize()
|
||||
if d.Action == "" {
|
||||
return fmt.Errorf("plan decision.action 不能为空")
|
||||
}
|
||||
|
||||
// 复杂度兜底:未填写时默认 moderate,不因此拒绝整个决策。
|
||||
switch d.Complexity {
|
||||
case PlanComplexitySimple, PlanComplexityModerate, PlanComplexityComplex:
|
||||
// ok
|
||||
case "":
|
||||
d.Complexity = PlanComplexityModerate
|
||||
default:
|
||||
return fmt.Errorf("未知 complexity: %s", d.Complexity)
|
||||
}
|
||||
|
||||
switch d.Action {
|
||||
case PlanActionContinue, PlanActionAskUser:
|
||||
if len(d.PlanSteps) > 0 {
|
||||
return fmt.Errorf("%s 动作不应携带 plan_steps", d.Action)
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
return fmt.Errorf("%s 动作不应携带 context_hook", d.Action)
|
||||
}
|
||||
return nil
|
||||
case PlanActionDone:
|
||||
if len(d.PlanSteps) == 0 {
|
||||
return fmt.Errorf("plan_done 动作必须携带完整 plan_steps")
|
||||
}
|
||||
for i := range d.PlanSteps {
|
||||
if err := d.PlanSteps[i].Validate(); err != nil {
|
||||
return fmt.Errorf("plan_steps[%d] 非法: %w", i, err)
|
||||
}
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
if err := d.ContextHook.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("未知 plan action: %s", d.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// PlanStep 表示规划阶段产出的一条自然语言步骤。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. Content 是步骤正文,后续可直接落到 CommonState.PlanSteps;
|
||||
// 2. DoneWhen 是可选的完成判定描述,用来给 execute 阶段提供最小退出条件;
|
||||
// 3. 这里仍然保持“自然语言优先”,不把 plan step 过度结构化。
|
||||
type PlanStep struct {
|
||||
Content string `json:"content"`
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 plan step 中的字符串字段。
|
||||
func (s *PlanStep) Normalize() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.Content = strings.TrimSpace(s.Content)
|
||||
s.DoneWhen = strings.TrimSpace(s.DoneWhen)
|
||||
}
|
||||
|
||||
// Validate 校验单条 plan step 的最小合法性。
|
||||
func (s *PlanStep) Validate() error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("plan step 不能为空")
|
||||
}
|
||||
s.Normalize()
|
||||
if s.Content == "" {
|
||||
return fmt.Errorf("plan step.content 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 context hook 字段。
|
||||
func (h *ContextHook) Normalize() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.Domain = normalizeContextHookDomain(h.Domain)
|
||||
h.Reason = strings.TrimSpace(h.Reason)
|
||||
h.Packs = normalizeContextHookPacks(h.Domain, h.Packs)
|
||||
}
|
||||
|
||||
// Validate 校验 context hook 最小合法性。
|
||||
func (h *ContextHook) Validate() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.Normalize()
|
||||
if h.Domain == "" {
|
||||
return fmt.Errorf("context_hook.domain 非法,仅支持 schedule/taskclass")
|
||||
}
|
||||
if h.Domain == "taskclass" && len(h.Packs) > 0 {
|
||||
return fmt.Errorf("context_hook.taskclass 暂不支持 packs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeContextHookDomain(domain string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case "schedule":
|
||||
return "schedule"
|
||||
case "taskclass":
|
||||
return "taskclass"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeContextHookPacks(domain string, packs []string) []string {
|
||||
if domain != "schedule" || len(packs) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]struct{}{
|
||||
"queue": {},
|
||||
"mutation": {},
|
||||
"analyze": {},
|
||||
"detail_read": {},
|
||||
"deep_analyze": {},
|
||||
"web": {},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
result := make([]string, 0, len(packs))
|
||||
for _, raw := range packs {
|
||||
pack := strings.ToLower(strings.TrimSpace(raw))
|
||||
if pack == "" || pack == "core" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[pack]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
result = append(result, pack)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
89
backend/services/agent/model/state_store.go
Normal file
89
backend/services/agent/model/state_store.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// AgentStateSnapshot 是需要持久化的 agent 运行态最小快照。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 只保存恢复执行所需的 RuntimeState 和 ConversationContext;
|
||||
// 2. 不保存 Request(每轮请求级,天然不跨连接);
|
||||
// 3. 不保存 Deps(依赖注入,每次由 Service 层重建);
|
||||
// 4. 不保存 ToolSchemas(每次请求由 Service 层重新注入)。
|
||||
type AgentStateSnapshot struct {
|
||||
RuntimeState *AgentRuntimeState `json:"runtime_state"`
|
||||
ConversationContext *ConversationContext `json:"conversation_context"`
|
||||
ScheduleState *schedule.ScheduleState `json:"schedule_state,omitempty"`
|
||||
OriginalScheduleState *schedule.ScheduleState `json:"original_schedule_state,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStateStore 定义 agent 状态持久化的最小接口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责"存 / 取 / 删"三个原子操作;
|
||||
// 2. 不负责序列化细节(由实现层决定 JSON / protobuf);
|
||||
// 3. 不负责业务级状态校验,校验仍在 node / graph 层完成。
|
||||
//
|
||||
// 实现层:
|
||||
// 1. dao/cache.go 上的 CacheDAO 隐式实现该接口(Go duck typing);
|
||||
// 2. agent 包不直接 import dao,由 Service 层在组装 Deps 时注入。
|
||||
type AgentStateStore interface {
|
||||
// Save 序列化并保存一份 agent 状态快照。
|
||||
//
|
||||
// 语义:
|
||||
// 1. 同一 conversationID 被覆盖写入,保证 Redis 里始终只有最新快照;
|
||||
// 2. 实现层应设 TTL,避免已完成的任务快照永不清理。
|
||||
Save(ctx context.Context, conversationID string, snapshot *AgentStateSnapshot) error
|
||||
|
||||
// Load 读取并反序列化 agent 状态快照。
|
||||
//
|
||||
// 返回值语义:
|
||||
// 1. (snapshot, true, nil):命中快照,正常返回;
|
||||
// 2. (nil, false, nil):未命中,不是错误,调用方应走新建对话路径;
|
||||
// 3. (nil, false, error):真正的存储层错误。
|
||||
Load(ctx context.Context, conversationID string) (*AgentStateSnapshot, bool, error)
|
||||
|
||||
// Delete 删除指定会话的 agent 状态快照。
|
||||
//
|
||||
// 语义:
|
||||
// 1. 删除是幂等的,key 不存在也视为成功;
|
||||
// 2. 典型调用时机:Deliver 节点任务完成后清理。
|
||||
Delete(ctx context.Context, conversationID string) error
|
||||
}
|
||||
|
||||
// ScheduleStateProvider 定义加载 ScheduleState 的接口。
|
||||
// 由 DAO 层或 Service 层实现,注入到 AgentGraphDeps 中。
|
||||
// 使用接口而非具体 DAO 类型,避免 model → dao 的循环依赖。
|
||||
type ScheduleStateProvider interface {
|
||||
LoadScheduleState(ctx context.Context, userID int) (*schedule.ScheduleState, error)
|
||||
// LoadTaskClassMetas 只加载指定任务类的约束元数据,供 Plan 节点提前消费。
|
||||
LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]schedule.TaskClassMeta, error)
|
||||
}
|
||||
|
||||
// ScopedScheduleStateProvider 定义“按本轮任务类范围加载 ScheduleState”的可选增强接口。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 负责:在 Execute / RoughBuild 首次加载状态时,把 DayMapping、TaskClasses 与 pending 任务限定在本轮 task_class_ids 相关窗口;
|
||||
// 2. 不负责:改变既有 ScheduleStateProvider 的基础能力,老实现仍可只实现 LoadScheduleState;
|
||||
// 3. 兜底策略:若调用方拿到的 provider 不实现该接口,则回退到全量 LoadScheduleState,再走工具层 scope 裁剪。
|
||||
type ScopedScheduleStateProvider interface {
|
||||
LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*schedule.ScheduleState, error)
|
||||
}
|
||||
|
||||
// CompactionStore 定义上下文压缩的持久化接口。
|
||||
// 由 Service 层实现(组合 DAO + Redis Cache),注入到各阶段 NodeInput。
|
||||
type CompactionStore interface {
|
||||
LoadCompaction(ctx context.Context, userID int, chatID string) (summary string, watermark int, err error)
|
||||
SaveCompaction(ctx context.Context, userID int, chatID string, summary string, watermark int) error
|
||||
SaveContextTokenStats(ctx context.Context, userID int, chatID string, statsJSON string) error
|
||||
|
||||
// LoadStageCompaction 按 stageKey 加载压缩摘要和水位线。
|
||||
// stageKey 区分不同节点(如 "execute"/"plan"/"chat"/"deliver"),
|
||||
// 使各节点可以独立维护各自的压缩状态,互不覆盖。
|
||||
LoadStageCompaction(ctx context.Context, userID int, chatID string, stageKey string) (summary string, watermark int, err error)
|
||||
// SaveStageCompaction 按 stageKey 保存压缩摘要和水位线。
|
||||
SaveStageCompaction(ctx context.Context, userID int, chatID string, stageKey string, summary string, watermark int) error
|
||||
}
|
||||
36
backend/services/agent/model/taskquery_contract.go
Normal file
36
backend/services/agent/model/taskquery_contract.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// TaskQueryParams 描述快捷任务查询路径传给业务层的内部查询参数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只承载“查询条件”本身,不负责 args 解析、默认值填充和错误提示;
|
||||
// 2. 所有字段均为轻量筛选语义,便于 quick_task 节点和 service 层直接复用;
|
||||
// 3. 不承担 LLM 工具协议,因为 query_tasks 工具链已下线。
|
||||
type TaskQueryParams struct {
|
||||
Quadrant *int
|
||||
SortBy string // deadline | priority | id
|
||||
Order string // asc | desc
|
||||
Limit int
|
||||
IncludeCompleted bool
|
||||
Keyword string
|
||||
DeadlineBefore *time.Time
|
||||
DeadlineAfter *time.Time
|
||||
}
|
||||
|
||||
// TaskQueryResult 描述快捷任务查询返回给上层的轻量任务视图。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只保留展示所需字段,避免把底层任务模型直接暴露给 agent 节点;
|
||||
// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出;
|
||||
// 3. 不负责序列化策略和文案渲染。
|
||||
type TaskQueryResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriorityGroup int `json:"priority_group"`
|
||||
EstimatedSections int `json:"estimated_sections"`
|
||||
PriorityLabel string `json:"priority_label"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
}
|
||||
27
backend/services/agent/model/taskquery_types.go
Normal file
27
backend/services/agent/model/taskquery_types.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// TaskQueryRequest 是任务查询工具的请求参数。
|
||||
type TaskQueryRequest struct {
|
||||
UserID int
|
||||
Quadrant *int
|
||||
SortBy string
|
||||
Order string
|
||||
Limit int
|
||||
IncludeCompleted bool
|
||||
Keyword string
|
||||
DeadlineBefore *time.Time
|
||||
DeadlineAfter *time.Time
|
||||
}
|
||||
|
||||
// TaskQueryTaskRecord 是任务查询工具返回的单条任务记录。
|
||||
type TaskQueryTaskRecord struct {
|
||||
ID int
|
||||
Title string
|
||||
PriorityGroup int
|
||||
EstimatedSections int
|
||||
IsCompleted bool
|
||||
DeadlineAt *time.Time
|
||||
UrgencyThresholdAt *time.Time
|
||||
}
|
||||
Reference in New Issue
Block a user