agent-team/internal/room/workflow.go
scorpio e6e8bd8ce1 feat: phase强制校验、重复分配防护、章节file call、成员对话模式
- 系统级phase强制校验:阻止跨阶段分配任务,前置阶段未完成时自动拦截
- 循环内去重:同phase同成员不会被重复执行,消除master重复分配问题
- 消除重复提示:feedbackMsg和continueMsg不再同时注入
- 动态章节file call:所有静态文件完成后自动触发masterChapterFileCall
- 章节内容安全网拦截:master在聊天中输出文档内容时自动保存到workspace
- 用户@成员对话:区分对话和任务分配,短消息/问候走对话路由而非任务流水线
- handleMemberConversation改进:初始化系统提示、流式输出、DB存储
- 策划编辑AGENT.md:新增前置依赖检查、评分必须引用文档数据来源
- TodoList更新提醒:任务完成后提醒master用编辑指令更新TodoList
- buildWorkflowStep强化:显示阶段依赖关系和后续阶段解锁提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:16:08 +08:00

585 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("<workflow_progress>\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("</workflow_progress>\n\n")
if minPendingPhase < 999 {
sb.WriteString(fmt.Sprintf("<next_action>\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("</next_action>\n")
sb.WriteString(fmt.Sprintf("请只分配 phase %d 的任务。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。", minPendingPhase))
} else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" {
// 所有静态文件已完成,进入动态章节写作阶段
existingChapters := r.listChapterFiles(dynDir)
sb.WriteString(fmt.Sprintf("<next_action>\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))
}
}
sb.WriteString("系统将自动发起章节文档调用,你不需要在回复中输出章节正文。\n")
sb.WriteString("请简短说明接下来要写哪一章,系统会自动发起文档调用。\n")
sb.WriteString("</next_action>\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, "正在编写章节...")
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 := fmt.Sprintf(
"[系统·章节文档调用] 请产出下一章的完整 Markdown 正文。\n"+
"要求:\n"+
"- 以 # 第X章 章节标题 开头\n"+
"- 只输出章节正文,不要夹杂任何交流内容\n"+
"- 不要说\"以下是\"\"下面是\"等引导语,直接输出正文\n"+
"- 系统会自动保存到 workspace/%s/ 目录下%s",
dir, 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,
})
}
// 发送完成状态到聊天
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, "\\", "-")
if !strings.HasSuffix(title, ".md") {
title += ".md"
}
return title
}
// buildProjectContext 构建项目模板上下文,注入到成员 system prompt
func (r *Room) buildProjectContext(agentName string) string {
if r.projectTemplate == nil {
return ""
}
var sb strings.Builder
sb.WriteString("<project_template>\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))
}
sb.WriteString("\n输出规范工作完成后最终回复只输出文件的 Markdown 正文内容(以 # 标题 开头)。\n")
sb.WriteString("系统会自动保存到对应文件。不要在文档中夹杂状态描述或对话内容。\n")
sb.WriteString("</project_template>")
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))
filePrompt := fmt.Sprintf(
"[系统·文档调用] 现在请产出文件《%s》的完整 Markdown 正文。\n要求\n"+
"- 以 # %s 开头\n"+
"- 只输出文档正文,不要夹杂任何交流内容\n"+
"- 不要说\"以下是\"\"下面是\"等引导语,直接输出文档\n"+
"- 系统会自动保存到 workspace/%s",
docName, docName, file.Path)
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
}
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,
})
}
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("<available_skills>\n")
sb.WriteString("以下工具可供团队成员使用,你可以在分配任务时提示成员使用对应的工具:\n")
for _, m := range r.skillMeta {
fmt.Fprintf(&sb, " - %s: %s\n", m.Name, m.Description)
}
sb.WriteString("</available_skills>")
return sb.String()
}
// buildTeamXML 构建团队成员 XML 上下文
func (r *Room) buildTeamXML() string {
var sb strings.Builder
sb.WriteString("<team_members>\n")
for name, a := range r.members {
fmt.Fprintf(&sb, " <member>\n <name>%s</name>\n <description>%s</description>\n </member>\n", name, a.Config.Description)
}
sb.WriteString("</team_members>")
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
}