package room import ( "context" "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "github.com/sdaduanbilei/agent-team/internal/llm" "github.com/sdaduanbilei/agent-team/internal/skill" "github.com/sdaduanbilei/agent-team/internal/store" ) // buildWorkflowStep 根据 workspace 中已存在的文件,构建 phase-aware 的工作流进度上下文 func (r *Room) buildWorkflowStep() string { if r.projectTemplate == nil { return "" } var completed []string var pending []string minPendingPhase := 999 for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); err == nil { completed = append(completed, f.Path) } else { pending = append(pending, fmt.Sprintf("%s (@%s, phase:%d)", f.Path, f.Owner, f.Phase)) if f.Phase < minPendingPhase { minPendingPhase = f.Phase } } } var sb strings.Builder sb.WriteString("\n") sb.WriteString("已完成的文件:\n") for _, f := range completed { sb.WriteString(fmt.Sprintf(" [done] %s\n", f)) } sb.WriteString("待产出的文件:\n") for _, f := range pending { sb.WriteString(fmt.Sprintf(" [todo] %s\n", f)) } sb.WriteString("\n\n") if minPendingPhase < 999 { sb.WriteString(fmt.Sprintf("\n当前阶段:phase %d\n", minPendingPhase)) sb.WriteString(fmt.Sprintf("⚠️ 严格规则:只能分配 phase %d 的任务,系统会阻止跨阶段分配。\n", minPendingPhase)) for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic || f.Phase != minPendingPhase { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); err == nil { continue } if f.Owner == r.master.Config.Name { sb.WriteString(fmt.Sprintf("- 你负责:%s(系统将自动发起文档调用,你不需要在此输出文档正文)\n", f.Path)) } else { sb.WriteString(fmt.Sprintf("- 分配给 @%s:%s\n", f.Owner, f.Path)) } } // 显示后续阶段作为提示 for phase := minPendingPhase + 1; phase <= 6; phase++ { var phaseFiles []string for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic || f.Phase != phase { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { phaseFiles = append(phaseFiles, fmt.Sprintf("%s(@%s)", f.Path, f.Owner)) } } if len(phaseFiles) > 0 { sb.WriteString(fmt.Sprintf("(后续 phase %d:%s — 当前阶段完成后自动解锁)\n", phase, strings.Join(phaseFiles, "、"))) } } sb.WriteString("\n") sb.WriteString(fmt.Sprintf("请只分配 phase %d 的任务。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。", minPendingPhase)) } else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" { // 所有静态文件已完成,进入动态章节写作阶段 existingChapters := r.listChapterFiles(dynDir) sb.WriteString(fmt.Sprintf("\n当前阶段:phase %d — 章节写作\n", dynPhase)) sb.WriteString(fmt.Sprintf("负责人:%s\n", dynOwner)) if len(existingChapters) > 0 { sb.WriteString("已完成章节:\n") for _, ch := range existingChapters { sb.WriteString(fmt.Sprintf(" [done] %s/%s\n", dynDir, ch)) } } isMasterDyn := r.master != nil && dynOwner == r.master.Config.Name if isMasterDyn { sb.WriteString("系统将自动发起章节文档调用,你不需要在回复中输出章节正文。\n") sb.WriteString("请简短说明接下来要写哪一章,系统会自动发起文档调用。\n") } else { sb.WriteString(fmt.Sprintf("请用 @%s 分配章节写作任务,任务中注明章节序号和标题。\n", dynOwner)) sb.WriteString("每次分配一章,写完后你会收到完成通知,再安排下一章。\n") } sb.WriteString("\n") } else { sb.WriteString("所有模板文件已完成。请根据你的 AGENT.md 工作流程决定下一步行动(如向用户交付、或进入下一阶段)。") } return sb.String() } // currentMinPhase 返回当前最小未完成 phase func (r *Room) currentMinPhase() int { if r.projectTemplate == nil { return 0 } minPhase := 999 for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { if f.Phase < minPhase { minPhase = f.Phase } } } if minPhase == 999 { return 0 } return minPhase } // validatePhaseAssignments 校验任务分配是否符合 phase 顺序 // 返回被阻止的分配及原因 func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string { if r.projectTemplate == nil { return nil } minPhase := r.currentMinPhase() if minPhase == 0 { return nil // 所有静态文件已完成 } blocked := make(map[string]string) for name := range assignments { targetFile := r.findMemberTargetFile(name) if targetFile == nil { continue // 没有模板文件,不校验 } if targetFile.Phase > minPhase { // 该成员的目标文件 phase 大于当前最小未完成 phase // 查找当前 phase 还有哪些文件未完成 var pendingInCurrentPhase []string for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic || f.Phase != minPhase { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { pendingInCurrentPhase = append(pendingInCurrentPhase, fmt.Sprintf("%s(@%s)", f.Path, f.Owner)) } } blocked[name] = fmt.Sprintf( "《%s》属于 phase:%d,但 phase:%d 还有未完成文件:%s", targetFile.Path, targetFile.Phase, minPhase, strings.Join(pendingInCurrentPhase, "、")) } } return blocked } // allStaticFilesDone 检查所有非动态模板文件是否都已完成 func (r *Room) allStaticFilesDone() bool { if r.projectTemplate == nil { return false } for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { return false } } return true } // getDynamicFileInfo 返回动态文件的目录名、负责人和阶段号 func (r *Room) getDynamicFileInfo() (dir, owner string, phase int) { if r.projectTemplate == nil { return "", "", 0 } // 找到 dynamic 条目,以及它前面的目录条目 lastDir := "" for _, f := range r.projectTemplate.Files { if f.IsDir { lastDir = strings.TrimSuffix(f.Path, "/") continue } if f.Dynamic { if lastDir == "" { lastDir = "chapters" } return lastDir, f.Owner, f.Phase } } return "", "", 0 } // listChapterFiles 列出动态目录下已有的章节文件 func (r *Room) listChapterFiles(dir string) []string { chDir := filepath.Join(r.Dir, "workspace", dir) entries, err := os.ReadDir(chDir) if err != nil { return nil } var files []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { files = append(files, e.Name()) } } return files } // masterChapterFileCall 为 master 发起一次章节文档调用 func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) { r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...") r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: dir + "/", Title: "章节"}) existingChapters := r.listChapterFiles(dir) var existingList string if len(existingChapters) > 0 { existingList = "\n已完成的章节:\n" for _, ch := range existingChapters { existingList += fmt.Sprintf("- %s\n", ch) } } filePrompt := r.Prompt.Render("file_call_chapter", map[string]string{ "Dir": dir, "ExistingChapters": existingList, }) if chapterHint != "" { filePrompt += "\n\n你之前的规划:" + chapterHint } if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { filePrompt += "\n\n" + wsCtx } fileLLMMsg := llm.NewMsg("user", filePrompt) *masterMsgs = append(*masterMsgs, fileLLMMsg) reply, usage, err := r.master.ChatWithUsage(ctx, *masterMsgs, nil) if err != nil { log.Printf("[room %s] master chapter file call error: %v", r.Config.Name, err) return } r.emitUsage(r.master.Config.Name, usage) content := strings.TrimSpace(reply) // 从内容中提取章节标题作为文件名 chapterFilename := r.extractChapterFilename(content, dir) fullPath := dir + "/" + chapterFilename if !strings.HasPrefix(content, "# ") { content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content } r.saveWorkspace(fullPath, content) docName := strings.TrimSuffix(chapterFilename, ".md") r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) if r.Store != nil { r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", Content: docName, Filename: fullPath, PartType: "document", GroupID: &r.currentGroupID, }) } r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) // 发送完成状态到聊天 statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, fullPath) r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true}) if r.Store != nil { r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, PartType: "text", GroupID: &r.currentGroupID, }) } assistantMsg := llm.NewMsg("assistant", reply) *masterMsgs = append(*masterMsgs, assistantMsg) r.historyMu.Lock() r.masterHistory = append(r.masterHistory, fileLLMMsg, assistantMsg) r.historyMu.Unlock() r.AppendHistory("master", r.master.Config.Name, reply) } // extractChapterFilename 从章节内容中提取文件名 func (r *Room) extractChapterFilename(content, dir string) string { title := extractTitle(content) if title == "" { // 按已有章节数自增 existing := r.listChapterFiles(dir) return fmt.Sprintf("第%d章.md", len(existing)+1) } // 清理标题中的特殊字符,作为文件名 title = strings.ReplaceAll(title, " ", "-") title = strings.ReplaceAll(title, "/", "-") title = strings.ReplaceAll(title, "\\", "-") // 先去除可能已有的 .md 后缀,再统一添加,避免双重扩展名 title = strings.TrimSuffix(title, ".md") title += ".md" return title } // buildProjectContext 构建项目模板上下文,注入到 agent system prompt func (r *Room) buildProjectContext(agentName string) string { if r.projectTemplate == nil { return "" } isMaster := r.master != nil && agentName == r.master.Config.Name var sb strings.Builder sb.WriteString("\n") sb.WriteString("项目文件结构(系统自动管理文件保存):\n\n") for _, f := range r.projectTemplate.Files { if f.IsDir { sb.WriteString(fmt.Sprintf(" %s (目录)\n", f.Path)) continue } if f.Dynamic { sb.WriteString(fmt.Sprintf(" ... @%s phase:%d (动态扩展)\n", f.Owner, f.Phase)) continue } marker := "" if f.Owner == agentName { marker = " ← 你负责" } sb.WriteString(fmt.Sprintf(" %s @%s phase:%d%s\n", f.Path, f.Owner, f.Phase, marker)) } if isMaster { // master:文档通过系统 file call 产出,聊天中只做规划和分配 sb.WriteString("\n" + r.Prompt.R("master_output_spec") + "\n") } else { // 成员:最终回复输出文档正文 sb.WriteString("\n" + r.Prompt.R("output_spec") + "\n") sb.WriteString("系统会自动保存到对应文件。不要在文档中夹杂状态描述或对话内容。\n") } sb.WriteString("") return sb.String() } // matchTemplateFile 按标题匹配模板文件。 func (r *Room) matchTemplateFile(title string) *ProjectFile { if r.projectTemplate == nil || title == "" { return nil } for i := range r.projectTemplate.Files { f := &r.projectTemplate.Files[i] if f.IsDir || f.Dynamic { continue } fname := strings.TrimSuffix(f.Path, ".md") if fname == title { return f } } for i := range r.projectTemplate.Files { f := &r.projectTemplate.Files[i] if f.IsDir || f.Dynamic { continue } fname := strings.TrimSuffix(f.Path, ".md") if strings.HasPrefix(title, fname) { return f } } for i := range r.projectTemplate.Files { f := &r.projectTemplate.Files[i] if f.IsDir || f.Dynamic { continue } base := strings.TrimSuffix(f.Path, ".md") if strings.Contains(base, title) || strings.Contains(title, base) { return f } keywords := strings.FieldsFunc(base, func(r rune) bool { return r == '与' || r == '和' || r == '·' }) for _, kw := range keywords { if len([]rune(kw)) >= 2 && strings.Contains(title, kw) { return f } } } return nil } // findOwnerFiles 查找某 agent 负责的所有文件 func (r *Room) findOwnerFiles(agentName string) []ProjectFile { if r.projectTemplate == nil { return nil } var files []ProjectFile for _, f := range r.projectTemplate.Files { if f.Owner == agentName && !f.IsDir && !f.Dynamic { files = append(files, f) } } return files } // findPendingMasterFiles 查找当前最小未完成 phase 中 master 负责的待产出文件 func (r *Room) findPendingMasterFiles() []ProjectFile { if r.projectTemplate == nil { return nil } minPhase := 999 for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { if f.Phase < minPhase { minPhase = f.Phase } } } if minPhase == 999 { return nil } var files []ProjectFile for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic || f.Phase != minPhase || f.Owner != r.master.Config.Name { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { files = append(files, f) } } return files } // masterFileCall 为 master 发起一次独立的文档产出调用(file call)。 // 输出直接保存到 workspace,不进入聊天记录。 func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile) { docName := strings.TrimSuffix(file.Path, ".md") r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在编写《%s》...", docName)) r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) filePrompt := r.Prompt.Render("file_call_master", map[string]string{ "DocName": docName, "FilePath": file.Path, }) // TodoList 特殊格式要求 if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") { filePrompt += "\n\n格式要求:每个任务项必须包含负责人,格式为 `- [ ] 任务描述 (@负责人)`。已完成的用 `- [x]`。" } if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { filePrompt += "\n\n" + wsCtx } fileLLMMsg := llm.NewMsg("user", filePrompt) *masterMsgs = append(*masterMsgs, fileLLMMsg) reply, usage, err := r.master.ChatWithUsage(ctx, *masterMsgs, nil) if err != nil { log.Printf("[room %s] master file call error for %s: %v", r.Config.Name, file.Path, err) return } r.emitUsage(r.master.Config.Name, usage) content := strings.TrimSpace(reply) if !strings.HasPrefix(content, "# ") { content = "# " + docName + "\n\n" + content } // TodoList 特殊处理:去除文件末尾可能混入的 @分配 指令文本 if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") { content = stripTrailingAssignments(content) } r.saveWorkspace(file.Path, content) r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) if r.Store != nil { r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", Content: docName, Filename: file.Path, PartType: "document", GroupID: &r.currentGroupID, }) } r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) assistantMsg := llm.NewMsg("assistant", reply) *masterMsgs = append(*masterMsgs, assistantMsg) r.historyMu.Lock() r.masterHistory = append(r.masterHistory, fileLLMMsg, assistantMsg) r.historyMu.Unlock() r.AppendHistory("master", r.master.Config.Name, reply) } // findMemberTargetFile 查找成员当前应产出的目标文件(用于 file call) func (r *Room) findMemberTargetFile(name string) *ProjectFile { ownerFiles := r.findOwnerFiles(name) if len(ownerFiles) == 0 { log.Printf("[room %s] findMemberTargetFile(%s): no owner files found", r.Config.Name, name) return nil } log.Printf("[room %s] findMemberTargetFile(%s): found %d owner files", r.Config.Name, name, len(ownerFiles)) if len(ownerFiles) == 1 { log.Printf("[room %s] findMemberTargetFile(%s): → %s", r.Config.Name, name, ownerFiles[0].Path) return &ownerFiles[0] } for i := range ownerFiles { fpath := filepath.Join(r.Dir, "workspace", ownerFiles[i].Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { log.Printf("[room %s] findMemberTargetFile(%s): → %s (not yet exists)", r.Config.Name, name, ownerFiles[i].Path) return &ownerFiles[i] } } result := &ownerFiles[len(ownerFiles)-1] log.Printf("[room %s] findMemberTargetFile(%s): → %s (fallback last)", r.Config.Name, name, result.Path) return result } // buildSkillSummary 为 master 构建简要的 skill 清单 func (r *Room) buildSkillSummary() string { if len(r.skillMeta) == 0 { return "" } var sb strings.Builder sb.WriteString("\n") sb.WriteString("以下工具可供团队成员使用,你可以在分配任务时提示成员使用对应的工具:\n") for _, m := range r.skillMeta { fmt.Fprintf(&sb, " - %s: %s\n", m.Name, m.Description) } sb.WriteString("") return sb.String() } // buildTeamXML 构建团队成员 XML 上下文 func (r *Room) buildTeamXML() string { var sb strings.Builder sb.WriteString("\n") for name, a := range r.members { fmt.Fprintf(&sb, " \n %s\n %s\n \n", name, a.Config.Description) } sb.WriteString("") return sb.String() } // executeToolCall 执行 tool call,返回执行结果 func (r *Room) executeToolCall(tc llm.ToolCall) string { var args struct { Command string `json:"command"` } if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return fmt.Sprintf("参数解析错误: %v", err) } skillPath := skill.SkillPathByToolName(r.skillMeta, tc.Function.Name) skillsRoot := filepath.Dir(skillPath) if skillPath == "" { skillsRoot = "skills" } if abs, err := filepath.Abs(skillsRoot); err == nil { skillsRoot = abs } log.Printf("[tool] 执行: %s, 命令: %s", tc.Function.Name, args.Command) cmd := exec.Command("bash", "-c", args.Command) cmd.Env = append(os.Environ(), "SKILLS_ROOT="+skillsRoot) cmd.Dir = r.Dir output, err := cmd.CombinedOutput() result := string(output) if err != nil { result = fmt.Sprintf("命令执行错误: %v\n输出:\n%s", err, result) } if len(result) > 10000 { result = result[:10000] + "\n... (输出已截断)" } log.Printf("[tool] 结果 (%d 字符): %.200s", len(result), result) return result }