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))
}
}
sb.WriteString("系统将自动发起章节文档调用,你不需要在回复中输出章节正文。\n")
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, "正在编写章节...")
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("\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("")
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("\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
}