package newagentprompt import ( "encoding/json" "fmt" "sort" "strconv" "strings" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" "github.com/cloudwego/eino/schema" ) const ( executeHistoryKindKey = "newagent_history_kind" executeHistoryKindCorrectionUser = "llm_correction_prompt" executeHistoryKindLoopClosed = "execute_loop_closed" executeHistoryKindStepAdvanced = "execute_step_advanced" // executeLoopWindowLimit 控制当轮 ReAct Loop 窗口最多保留多少条记录。 executeLoopWindowLimit = 8 // executeTrimmedObservationText 是重复工具压缩后的 observation 占位文案。 executeTrimmedObservationText = "当前工具调用结果已经被使用过,当前无需使用,为节省上下文空间,已折叠" // executeConversationTurnLimit 控制 msg1 注入的最大对话轮数(user + assistant speak)。 // 超出时保留最近的条目,早期部分由 ReAct 摘要兜底。 executeConversationTurnLimit = 30 ) type executeToolSchemaDoc struct { Name string `json:"name"` Parameters map[string]any `json:"parameters"` } type executeLoopRecord struct { Thought string ToolName string ToolArgs string Observation string } const executeMessage1MaxRunes = 1400 // buildExecuteStageMessages 组装 execute 阶段 4 条消息骨架。 // // 消息结构(固定): // 1. message[0] 固定 prompt(规则 + 微调硬引导 + 输出约束 + 工具简表) // 2. message[1] 历史上下文(真实对话流 + 早期 ReAct 摘要) // 3. message[2] 当轮 ReAct Loop 窗口(thought/reason + tool_call + observation 绑定展示) // 4. message[3] 当前执行状态(轮次、模式、plan 步骤、任务类、相关记忆等) func buildExecuteStageMessages( stageSystemPrompt string, state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string, ) []*schema.Message { msg0 := buildExecuteMessage0(stageSystemPrompt, ctx) msg1 := buildExecuteMessage1V3(ctx) msg2 := buildExecuteMessage2V3(ctx) msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt) return []*schema.Message{ schema.SystemMessage(msg0), {Role: schema.Assistant, Content: msg1}, {Role: schema.Assistant, Content: msg2}, schema.SystemMessage(msg3), } } // buildExecuteMessage0 生成固定规则消息,并附带工具简表。 func buildExecuteMessage0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string { base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt)) if base == "" { base = "你是 SmartMate 执行器,请继续 execute 阶段。" } toolCatalog := renderExecuteToolCatalogCompact(ctx) if toolCatalog == "" { return base } return base + "\n\n" + toolCatalog } // splitExecuteLoopRecordsByBoundary 按已收口标记拆分归档/活跃 ReAct 记录。 // // 规则: // 1. 标记之前的记录归档到 msg1; // 2. 标记之后的记录作为活跃 loop 进入 msg2; // 3. 若没有标记,则全部视为活跃记录(兼容旧会话快照)。 func splitExecuteLoopRecordsByBoundary(history []*schema.Message) (archived []executeLoopRecord, active []executeLoopRecord) { if len(history) == 0 { return nil, nil } boundary := findLatestExecuteBoundaryMarker(history) if boundary < 0 { return nil, collectExecuteLoopRecords(history) } if boundary > 0 { archived = collectExecuteLoopRecords(history[:boundary]) } if boundary+1 < len(history) { active = collectExecuteLoopRecords(history[boundary+1:]) } return archived, active } func findLatestExecuteBoundaryMarker(history []*schema.Message) int { for i := len(history) - 1; i >= 0; i-- { msg := history[i] if msg == nil || msg.Extra == nil { continue } kind, ok := msg.Extra[executeHistoryKindKey].(string) if !ok { continue } switch strings.TrimSpace(kind) { case executeHistoryKindLoopClosed, executeHistoryKindStepAdvanced: return i } } return -1 } func trimExecuteMessage1ByBudget(content string) string { content = strings.TrimSpace(content) if content == "" { return "" } runes := []rune(content) if len(runes) <= executeMessage1MaxRunes { return content } if executeMessage1MaxRunes <= 3 { return string(runes[:executeMessage1MaxRunes]) } return string(runes[:executeMessage1MaxRunes-3]) + "..." } // buildExecuteMessage1V3 负责把真实对话流 + 上一轮 loop 归档并入 msg1,并统一做长度裁剪。 // // 改造说明: // 1. msg1 从人工提炼的摘要变为真实对话流,只注入 user + assistant speak; // 2. tool_call / observation 不在 msg1 中重复(已由 msg2 承载); // 3. 超出 executeConversationTurnLimit 的早期对话不注入,由 ReAct 摘要兜底。 func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string { lines := []string{"历史上下文:"} if ctx == nil { lines = append(lines, "- 对话历史:暂无。", "- 阶段锚点:按当前工具事实推进执行。", "- 历史归档 ReAct 摘要:暂无。", "- 历史归档 ReAct 窗口:暂无。", "- 当前循环早期摘要:暂无。", ) return strings.Join(lines, "\n") } history := ctx.HistorySnapshot() // 注入真实对话流(user + assistant speak),全量放入,不再限制轮数和单条长度。 turns := collectExecuteConversationTurns(history) if len(turns) == 0 { lines = append(lines, "- 对话历史:暂无。") } else { turnLines := make([]string, 0, len(turns)+1) turnLines = append(turnLines, "对话历史:") for _, turn := range turns { turnLines = append(turnLines, turn.Role+": \""+turn.Content+"\"") } lines = append(lines, strings.Join(turnLines, "\n")) } if hasExecuteRoughBuildDone(ctx) { lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。") } else { lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。") } archivedLoops, activeLoops := splitExecuteLoopRecordsByBoundary(history) lines = append(lines, "- 历史归档 ReAct 摘要:"+buildEarlyExecuteReactSummary(archivedLoops, executeLoopWindowLimit)) lines = append(lines, renderArchivedExecuteLoopWindowForMessage1V3(archivedLoops)) lines = append(lines, "- 当前循环早期摘要:"+buildEarlyExecuteReactSummary(activeLoops, executeLoopWindowLimit)) return strings.Join(lines, "\n") } // buildExecuteMessage2V3 承载当前活跃 loop 的全部记录。 // 若是新一轮刚开始(活跃 loop 为空),明确返回已清空状态。 // 不再限制窗口大小,token 预算由 execute 层统一管理。 func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string { lines := []string{"当轮 ReAct Loop 记录:"} if ctx == nil { lines = append(lines, "- 暂无可用 ReAct 记录。") return strings.Join(lines, "\n") } _, activeLoops := splitExecuteLoopRecordsByBoundary(ctx.HistorySnapshot()) if len(activeLoops) == 0 { lines = append(lines, "- 已清空(新一轮 loop 准备中)。") return strings.Join(lines, "\n") } // 全量放入,不再限制窗口大小 for i, loop := range activeLoops { lines = append(lines, fmt.Sprintf("%d) thought/reason:%s", i+1, loop.Thought)) lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs))) lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation)) } return strings.Join(lines, "\n") } func renderArchivedExecuteLoopWindowForMessage1V3(records []executeLoopRecord) string { if len(records) == 0 { return "- 历史归档 ReAct 窗口:暂无。" } windowLoops := tailExecuteLoops(records, executeLoopWindowLimit) windowLoops = compressExecuteLoopObservationsByTool(windowLoops) lines := []string{"历史归档 ReAct 窗口(由上一轮 msg2 并入):"} for i, loop := range windowLoops { lines = append(lines, fmt.Sprintf("%d) thought/reason:%s", i+1, loop.Thought)) lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs))) lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation)) } return strings.Join(lines, "\n") } func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string { lines := []string{"当前执行状态:"} roundUsed, maxRounds := 0, newagentmodel.DefaultMaxRounds modeText := "自由执行(无预定义步骤)" if state != nil { roundUsed = state.RoundUsed if state.MaxRounds > 0 { maxRounds = state.MaxRounds } if state.HasPlan() { modeText = "计划执行(有预定义步骤)" } } lines = append(lines, fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds), "- 当前模式:"+modeText, ) // 1. 有 plan 时,把当前步骤与完成判定强制写入 msg3。 // 2. 该锚点用于约束模型只推进当前步骤,避免退化成泛化 ReAct。 // 3. 当前步骤不可读时给出兜底指引,避免引用旧步骤。 if state != nil && state.HasPlan() { current, total := state.PlanProgress() lines = append(lines, "计划步骤锚点(强约束):") if step, ok := state.CurrentPlanStep(); ok { stepContent := strings.TrimSpace(step.Content) if stepContent == "" { stepContent = "(当前步骤内容为空)" } doneWhen := strings.TrimSpace(step.DoneWhen) if doneWhen == "" { doneWhen = "(未提供 done_when,需基于步骤目标给出可验证完成证据)" } lines = append(lines, fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total)) lines = append(lines, "- 当前步骤内容:"+stepContent) lines = append(lines, "- 当前步骤完成判定(done_when):"+doneWhen) lines = append(lines, "- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan") lines = append(lines, "- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据") lines = append(lines, "- 动作纪律3:禁止跳到后续步骤执行") } else { lines = append(lines, "- 当前计划步骤不可读;请先判断是否已完成全部计划") lines = append(lines, "- 若已完成全部计划,输出 done 并给出 goal_check 证据") } } if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" { lines = append(lines, "- 目标任务类:"+taskClassText) } lines = append(lines, "- 啥时候结束Loop:你可以根据工具调用记录自行判断。") lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。") if hasExecuteRoughBuildDone(ctx) { lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不作为可移动目标。") } lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。") if state != nil { if state.AllowReorder { lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。") } else { lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。") } } if memoryText := renderExecuteMemoryContext(ctx); memoryText != "" { lines = append(lines, "相关记忆(仅在确有帮助时参考,不要机械复述):") lines = append(lines, memoryText) } // 兼容上层传入的执行指令;若为空则使用固定收口指令。 instruction := strings.TrimSpace(runtimeUserPrompt) if instruction == "" { instruction = "请继续当前任务执行阶段,严格输出 JSON。" } else { instruction = firstExecuteLine(instruction) } lines = append(lines, "本轮指令:"+instruction) return strings.Join(lines, "\n") } // renderExecuteToolCatalogCompact 将工具 schema 渲染成简表,避免大段 JSON 示例占用上下文。 func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) string { if ctx == nil { return "" } schemas := ctx.ToolSchemasSnapshot() if len(schemas) == 0 { return "" } lines := []string{"可用工具(简表):"} for i, schemaItem := range schemas { name := strings.TrimSpace(schemaItem.Name) desc := strings.TrimSpace(schemaItem.Desc) if name == "" { continue } if desc == "" { desc = "无描述" } lines = append(lines, fmt.Sprintf("%d. %s:%s", i+1, name, desc)) doc := parseExecuteToolSchema(schemaItem.SchemaText) paramSummary := renderExecuteToolParamSummary(doc.Parameters) lines = append(lines, " 参数:"+paramSummary) returnType, returnSample := renderExecuteToolReturnHint(name) lines = append(lines, " 返回类型:"+returnType) lines = append(lines, " 返回示例:"+returnSample) } return strings.Join(lines, "\n") } // renderExecuteToolReturnHint 返回工具的返回类型 + 最小示例。 func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) { returnType = "string(自然语言文本)" switch strings.ToLower(strings.TrimSpace(toolName)) { case "get_overview": return returnType, "规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)..." case "get_task_info": return returnType, "[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节" case "query_available_slots": return "string(JSON字符串)", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}` case "query_target_tasks": return "string(JSON字符串)", `{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}` case "queue_pop_head": return "string(JSON字符串)", `{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}` case "queue_status": return "string(JSON字符串)", `{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}` case "queue_apply_head_move": return "string(JSON字符串)", `{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}` case "queue_skip_head": return "string(JSON字符串)", `{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}` case "query_range": return returnType, "第5天第3-6节:第3节空、第4节空..." case "place": return returnType, "已将 [35]... 预排到第5天第3-4节。" case "move": return returnType, "已将 [35]... 从第3天第5-6节移至第5天第3-4节。" case "swap": return returnType, "交换完成:[35]... ↔ [36]..." case "batch_move": return returnType, "批量移动完成,2个任务全部成功。(单次最多2条)" case "spread_even": return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。" case "min_context_switch": return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。" case "unplace": return returnType, "已将 [35]... 移除,恢复为待安排状态。" case "web_search": return "string(JSON字符串)", `{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}` case "web_fetch": return "string(JSON字符串)", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}` default: return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。" } } func parseExecuteToolSchema(schemaText string) executeToolSchemaDoc { doc := executeToolSchemaDoc{Parameters: map[string]any{}} schemaText = strings.TrimSpace(schemaText) if schemaText == "" { return doc } if err := json.Unmarshal([]byte(schemaText), &doc); err != nil { return doc } if doc.Parameters == nil { doc.Parameters = map[string]any{} } return doc } func renderExecuteToolParamSummary(parameters map[string]any) string { if len(parameters) == 0 { return "{}" } keys := make([]string, 0, len(parameters)) for key := range parameters { keys = append(keys, key) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { status := "可选" typeText := "" switch typed := parameters[key].(type) { case string: status = "必填" typeText = strings.TrimSpace(typed) case map[string]any: if required, ok := typed["required"].(bool); ok && required { status = "必填" } typeText = strings.TrimSpace(asExecuteString(typed["type"])) if enumRaw, ok := typed["enum"].([]any); ok && len(enumRaw) > 0 { enumText := make([]string, 0, len(enumRaw)) for _, item := range enumRaw { enumText = append(enumText, fmt.Sprintf("%v", item)) } if typeText == "" { typeText = "enum" } typeText += ":" + strings.Join(enumText, "/") } } if typeText == "" { parts = append(parts, fmt.Sprintf("%s(%s)", key, status)) continue } parts = append(parts, fmt.Sprintf("%s(%s,%s)", key, status, typeText)) } return strings.Join(parts, ";") } // collectExecuteLoopRecords 从历史中提取 ReAct 记录。 // // 提取策略: // 1. 以 assistant tool_call 消息为主键; // 2. 关联同 ToolCallID 的 tool result 作为 observation; // 3. 向前回溯最近一条 assistant 文本消息作为 thought/reason。 func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord { if len(history) == 0 { return nil } toolResultByCallID := make(map[string]*schema.Message, len(history)) for _, msg := range history { if msg == nil || msg.Role != schema.Tool { continue } callID := strings.TrimSpace(msg.ToolCallID) if callID == "" { continue } toolResultByCallID[callID] = msg } records := make([]executeLoopRecord, 0, len(history)) for i, msg := range history { if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 { continue } thought := findExecuteThoughtBefore(history, i) for _, call := range msg.ToolCalls { toolName := strings.TrimSpace(call.Function.Name) if toolName == "" { toolName = "unknown_tool" } toolArgs := compactExecuteText(call.Function.Arguments, 160) if toolArgs == "" { toolArgs = "{}" } observation := "该工具调用尚未返回结果。" callID := strings.TrimSpace(call.ID) if callID != "" { if resultMsg, ok := toolResultByCallID[callID]; ok && resultMsg != nil { text := strings.TrimSpace(resultMsg.Content) if text != "" { observation = text } } } records = append(records, executeLoopRecord{ Thought: thought, ToolName: toolName, ToolArgs: toolArgs, Observation: observation, }) } } return records } func findExecuteThoughtBefore(history []*schema.Message, index int) string { for i := index - 1; i >= 0; i-- { msg := history[i] if msg == nil || msg.Role != schema.Assistant { continue } if len(msg.ToolCalls) > 0 { continue } content := compactExecuteText(msg.Content, 140) if content == "" { continue } return content } return "(未记录)" } func tailExecuteLoops(records []executeLoopRecord, limit int) []executeLoopRecord { if len(records) == 0 { return nil } if limit <= 0 || len(records) <= limit { result := make([]executeLoopRecord, len(records)) copy(result, records) return result } result := make([]executeLoopRecord, limit) copy(result, records[len(records)-limit:]) return result } // compressExecuteLoopObservationsByTool 对窗口内重复工具做 observation 压缩。 func compressExecuteLoopObservationsByTool(records []executeLoopRecord) []executeLoopRecord { if len(records) == 0 { return records } latestIndexByTool := make(map[string]int, len(records)) for i := len(records) - 1; i >= 0; i-- { key := strings.ToLower(strings.TrimSpace(records[i].ToolName)) if key == "" { key = "unknown_tool" } if _, exists := latestIndexByTool[key]; !exists { latestIndexByTool[key] = i } } result := make([]executeLoopRecord, len(records)) copy(result, records) for i := range result { key := strings.ToLower(strings.TrimSpace(result[i].ToolName)) if key == "" { key = "unknown_tool" } if latestIndexByTool[key] != i { result[i].Observation = executeTrimmedObservationText } } return result } func renderExecuteToolCallText(toolName, toolArgs string) string { toolName = strings.TrimSpace(toolName) if toolName == "" { toolName = "unknown_tool" } toolArgs = strings.TrimSpace(toolArgs) if toolArgs == "" { toolArgs = "{}" } return toolName + "(" + toolArgs + ")" } func buildEarlyExecuteReactSummary(records []executeLoopRecord, windowLimit int) string { if len(records) == 0 { return "暂无。" } if len(records) <= windowLimit { return "无(当前窗口已覆盖全部 ReAct 记录)。" } early := records[:len(records)-windowLimit] toolCounts := make(map[string]int, len(early)) for _, record := range early { key := strings.TrimSpace(record.ToolName) if key == "" { key = "unknown_tool" } toolCounts[key]++ } names := make([]string, 0, len(toolCounts)) for name := range toolCounts { names = append(names, name) } sort.Strings(names) parts := make([]string, 0, len(names)) for _, name := range names { parts = append(parts, fmt.Sprintf("%s×%d", name, toolCounts[name])) } return fmt.Sprintf("已折叠 %d 条旧记录,涉及:%s。", len(early), strings.Join(parts, "、")) } func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool { if ctx == nil { return false } for _, block := range ctx.PinnedBlocksSnapshot() { if strings.TrimSpace(block.Key) == "rough_build_done" { return true } } return false } // conversationTurn 表示对话历史中的一轮交互(user 或 assistant speak)。 type conversationTurn struct { Role string Content string } // collectExecuteConversationTurns 从历史消息中提取 user + assistant speak 对话流。 // // 提取规则: // 1. 只保留 user 消息(排除 correction prompt)和 assistant speak 消息(非空 Content 且无 ToolCalls); // 2. 全量保留,不再限制轮数和单条长度(token 预算由 execute 层统一管理); // 3. 返回的条目按原始时间顺序排列。 func collectExecuteConversationTurns(history []*schema.Message) []conversationTurn { if len(history) == 0 { return nil } turns := make([]conversationTurn, 0, len(history)) for _, msg := range history { if msg == nil { continue } text := strings.TrimSpace(msg.Content) if text == "" { continue } switch msg.Role { case schema.User: if isExecuteCorrectionPrompt(msg) { continue } turns = append(turns, conversationTurn{Role: "user", Content: text}) case schema.Assistant: if len(msg.ToolCalls) > 0 { continue } turns = append(turns, conversationTurn{Role: "assistant", Content: text}) } } return turns } func isExecuteCorrectionPrompt(msg *schema.Message) bool { if msg == nil || msg.Role != schema.User { return false } if msg.Extra != nil { if kind, ok := msg.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindCorrectionUser { return true } } content := strings.TrimSpace(msg.Content) return strings.Contains(content, "请重新分析当前状态,输出正确的内容。") } func compactExecuteText(content string, maxLen int) string { content = firstExecuteLine(content) content = strings.TrimSpace(content) if content == "" { return "" } runes := []rune(content) if len(runes) <= maxLen { return content } if maxLen <= 3 { return string(runes[:maxLen]) } return string(runes[:maxLen-3]) + "..." } func firstExecuteLine(content string) string { content = strings.TrimSpace(content) if content == "" { return "" } lines := strings.Split(content, "\n") return strings.TrimSpace(lines[0]) } func asExecuteString(value any) string { if text, ok := value.(string); ok { return text } return "" } func renderExecuteTaskClassIDs(state *newagentmodel.CommonState) string { if state == nil || len(state.TaskClassIDs) == 0 { return "" } parts := make([]string, len(state.TaskClassIDs)) for i, id := range state.TaskClassIDs { parts[i] = strconv.Itoa(id) } return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ",")) }