agent-team/internal/room/workflow.go
sdaduanbilei 8cb4e041c8 fix
2026-03-09 17:38:43 +08:00

609 lines
19 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))
}
}
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 顺序
// 返回被阻止的分配及原因
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("<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 查找当前最小未完成 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("<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
}