agent-team/internal/room/workflow.go

690 lines
22 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("<next_action>\n当前阶段前置材料准备\n")
sb.WriteString("待产出的前置材料:\n")
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 {
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))
}
}
sb.WriteString("</next_action>\n")
sb.WriteString("请安排产出待完成的前置材料。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。")
} 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\n", 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("</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:2 任务时检查前置材料phase:1是否全部就绪
// 返回被阻止的分配及原因
func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string {
if r.projectTemplate == nil {
return nil
}
// 检查所有 phase:1 文件是否已完成
allPhase1Done := true
var pendingPhase1 []string
for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Phase != 1 {
continue
}
fpath := filepath.Join(r.Dir, "workspace", f.Path)
if _, err := os.Stat(fpath); os.IsNotExist(err) {
allPhase1Done = false
pendingPhase1 = append(pendingPhase1, fmt.Sprintf("%s(@%s)", f.Path, f.Owner))
}
}
if allPhase1Done {
return nil // 前置材料全部完成,不阻止任何分配
}
// 只阻止 phase:2写手/章节的分配phase:1 内部不互相阻止
blocked := make(map[string]string)
for name := range assignments {
targetFile := r.findMemberTargetFile(name)
if targetFile == nil {
continue
}
if targetFile.Phase > 1 {
blocked[name] = fmt.Sprintf(
"《%s》属于 phase:%d但前置材料尚未全部完成%s",
targetFile.Path, targetFile.Phase,
strings.Join(pendingPhase1, "、"))
}
}
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 列出 workspace 根目录下的章节文件(非模板静态文件的 .md 文件)
func (r *Room) listChapterFiles(dir string) []string {
wsDir := filepath.Join(r.Dir, "workspace")
entries, err := os.ReadDir(wsDir)
if err != nil {
return nil
}
// 收集模板静态文件名,用于排除
staticFiles := make(map[string]bool)
if r.projectTemplate != nil {
for _, f := range r.projectTemplate.Files {
if !f.IsDir && !f.Dynamic {
staticFiles[f.Path] = true
}
}
}
var files []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
if staticFiles[e.Name()] {
continue
}
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: "章节", 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)
// 从内容中提取章节标题作为文件名(直接保存到 workspace 根目录,不创建子文件夹)
chapterFilename := r.extractChapterFilename(content, dir)
if !strings.HasPrefix(content, "# ") {
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
}
r.saveWorkspace(chapterFilename, content)
docName := strings.TrimSuffix(chapterFilename, ".md")
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, 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: chapterFilename, PartType: "document",
GroupID: &r.currentGroupID,
})
}
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName})
// 发送完成状态到聊天
statusMsg := fmt.Sprintf("《%s》已完成保存到 %s。", docName, chapterFilename)
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("<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))
}
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("</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 查找 master 负责的所有待产出文件(不限 phase
func (r *Room) findPendingMasterFiles() []ProjectFile {
if r.projectTemplate == nil {
return nil
}
var files []ProjectFile
for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || 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,
})
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,
})
}
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
}
// parseMasterUpdateIntent 从 master 回复中解析更新文件的意图,返回需要更新的已存在模板文件
func (r *Room) parseMasterUpdateIntent(reply string) []ProjectFile {
if r.projectTemplate == nil {
return nil
}
// 检查 master 回复中是否提到了更新/修改某个已存在的模板文件
// 关键词:已更新、已修改、已调整、更新了、修改了
updateKeywords := []string{"已更新", "已修改", "已调整", "更新了", "修改了", "修改为", "改为", "调整为"}
hasUpdateIntent := false
for _, kw := range updateKeywords {
if strings.Contains(reply, kw) {
hasUpdateIntent = true
break
}
}
if !hasUpdateIntent {
return nil
}
var files []ProjectFile
seen := make(map[string]bool)
for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Owner != r.master.Config.Name {
continue
}
fpath := filepath.Join(r.Dir, "workspace", f.Path)
if _, err := os.Stat(fpath); err != nil {
continue // 文件不存在,不是更新场景
}
// 检查 master 是否在回复中提到了这个文件
docName := strings.TrimSuffix(f.Path, ".md")
if strings.Contains(reply, docName) || strings.Contains(reply, "《"+docName+"》") {
if !seen[f.Path] {
seen[f.Path] = true
files = append(files, f)
}
}
}
return files
}
// masterFileUpdateCall 为 master 发起一次文件更新调用(更新已存在的文件)
func (r *Room) masterFileUpdateCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile, updateHint string) {
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})
// 读取现有文件内容
currentContent := ""
fpath := filepath.Join(r.Dir, "workspace", file.Path)
if data, err := os.ReadFile(fpath); err == nil {
currentContent = string(data)
}
filePrompt := r.Prompt.Render("file_call_master_update", map[string]string{
"DocName": docName,
"FilePath": file.Path,
"UpdateHint": updateHint,
"CurrentContent": currentContent,
})
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 update 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,
})
}
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
// 发送完成状态到聊天
statusMsg := fmt.Sprintf("《%s》已更新。", docName)
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)
}
// 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
}