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"` }