package room import ( "bytes" "context" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" "github.com/sdaduanbilei/agent-team/internal/agent" "github.com/sdaduanbilei/agent-team/internal/llm" "github.com/sdaduanbilei/agent-team/internal/skill" "github.com/sdaduanbilei/agent-team/internal/user" "gopkg.in/yaml.v3" ) type RoomType string const ( TypeProject RoomType = "project" ) type Status string const ( StatusPending Status = "pending" StatusThinking Status = "thinking" StatusWorking Status = "working" ) type Config struct { Name string `yaml:"name"` Type RoomType `yaml:"type"` Master string `yaml:"master"` // agent name Members []string `yaml:"members"` // agent names Color string `yaml:"color"` // avatar color Team string `yaml:"team"` // installed team name } type Room struct { Config Config Dir string master *agent.Agent members map[string]*agent.Agent skillMeta []skill.Meta User *user.User Status Status ActiveAgent string // for working status display Broadcast func(Event) // set by api layer // master 的会话历史,保持多轮对话上下文 masterHistory []llm.Message historyMu sync.Mutex Mode string // "plan" | "build" pendingAssignments map[string]string // plan 模式下暂存的待执行任务 pendingPlanReply string // master 规划原文,用于生成计划文档 // Build 模式下成员对话跟踪 memberConvos map[string][]llm.Message // 成员名 -> 多轮对话历史 lastActiveMember string // 最后一个发出提问的成员 planFilename string // 当前任务计划文件名 } type EventType string const ( EvtAgentMessage EventType = "agent_message" EvtTaskAssign EventType = "task_assign" EvtReview EventType = "review" EvtRoomStatus EventType = "room_status" EvtTasksUpdate EventType = "tasks_update" EvtWorkspaceFile EventType = "workspace_file" EvtModeChange EventType = "mode_change" EvtArtifact EventType = "artifact" ) type Event struct { Type EventType `json:"type"` RoomID string `json:"room_id"` Agent string `json:"agent,omitempty"` Role string `json:"role,omitempty"` // master | member | challenge Content string `json:"content,omitempty"` Streaming bool `json:"streaming,omitempty"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` Task string `json:"task,omitempty"` Feedback string `json:"feedback,omitempty"` Status Status `json:"status,omitempty"` ActiveAgent string `json:"active_agent,omitempty"` Action string `json:"action,omitempty"` Filename string `json:"filename,omitempty"` Mode string `json:"mode,omitempty"` Title string `json:"title,omitempty"` } type BoardEntry struct { Author string Content string Type string // "draft" | "challenge" } type SharedBoard struct { mu sync.RWMutex entries []BoardEntry } func (b *SharedBoard) Add(author, content, typ string) { b.mu.Lock() defer b.mu.Unlock() b.entries = append(b.entries, BoardEntry{Author: author, Content: content, Type: typ}) } func (b *SharedBoard) ToContext() string { b.mu.RLock() defer b.mu.RUnlock() if len(b.entries) == 0 { return "" } var sb strings.Builder sb.WriteString("\n") for _, e := range b.entries { fmt.Fprintf(&sb, " \n%s\n \n", e.Type, e.Author, e.Content) } sb.WriteString("") return sb.String() } func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) { data, err := os.ReadFile(filepath.Join(roomDir, "room.md")) if err != nil { return nil, err } cfg, err := parseRoomConfig(data) if err != nil { return nil, err } r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan"} if cfg.Master != "" { agentPath := resolveAgentPath(agentsDir, cfg.Team, cfg.Master) r.master, err = agent.Load(agentPath) if err != nil { return nil, fmt.Errorf("load master %s: %w", cfg.Master, err) } for _, name := range cfg.Members { a, err := agent.Load(resolveAgentPath(agentsDir, cfg.Team, name)) if err != nil { return nil, fmt.Errorf("load member %s: %w", name, err) } r.members[name] = a } } r.skillMeta, _ = skill.Discover(skillsDir) return r, nil } func (r *Room) emit(e Event) { e.RoomID = r.Config.Name if r.Broadcast != nil { r.Broadcast(e) } } func (r *Room) setMode(mode string) { r.Mode = mode r.emit(Event{Type: EvtModeChange, Mode: mode}) } func (r *Room) setStatus(s Status, activeAgent, action string) { r.Status = s r.ActiveAgent = activeAgent r.emit(Event{Type: EvtRoomStatus, Status: s, ActiveAgent: activeAgent, Action: action}) } // AppendHistory persists a message to today's history file. func (r *Room) AppendHistory(role, agentName, content string) { dir := filepath.Join(r.Dir, "history") os.MkdirAll(dir, 0755) filename := filepath.Join(dir, time.Now().Format("2006-01-02")+".md") line := fmt.Sprintf("\n**[%s] %s** (%s)\n\n%s\n", time.Now().Format("15:04:05"), agentName, role, content) f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return } defer f.Close() f.WriteString(line) } func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]string, board *SharedBoard, skillXML string) map[string]string { results := make(map[string]string) var mu sync.Mutex var wg sync.WaitGroup for memberName, task := range assignments { wg.Add(1) go func(name, t string) { defer wg.Done() member, ok := r.members[name] if !ok { return } r.setStatus(StatusWorking, member.Config.Name, t) r.emit(Event{Type: EvtTaskAssign, From: r.master.Config.Name, To: name, Task: t}) // Build 模式:发送简要状态消息,不流式输出内容 taskBrief := t if len(taskBrief) > 60 { taskBrief = taskBrief[:60] + "..." } r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: fmt.Sprintf("正在处理: %s", taskBrief)}) boardCtx := board.ToContext() extraCtx := skillXML if boardCtx != "" { extraCtx = boardCtx + "\n\n" + skillXML } memberSystem := member.BuildSystemPrompt(extraCtx) memberMsgs := []llm.Message{ llm.NewMsg("system", memberSystem), llm.NewMsg("user", t), } // 静默收集输出,不流式推送到聊天 var memberReply strings.Builder _, err := member.Chat(ctx, memberMsgs, func(token string) { memberReply.WriteString(token) }) if err != nil { mu.Lock() results[name] = fmt.Sprintf("[error] %v", err) mu.Unlock() return } result := memberReply.String() mu.Lock() results[name] = result mu.Unlock() r.AppendHistory("member", name, result) board.Add(name, result, "draft") // 保存成员对话历史,支持后续多轮交互 if r.memberConvos == nil { r.memberConvos = make(map[string][]llm.Message) } r.memberConvos[name] = memberMsgs r.memberConvos[name] = append(r.memberConvos[name], llm.NewMsg("assistant", result)) // 智能判断:文档 → artifact,对话/提问 → 聊天消息 if isDocument(result) { title := extractTitle(result) filename := fmt.Sprintf("%s-%s.md", name, time.Now().Format("20060102-150405")) r.saveWorkspace(filename, result) r.emit(Event{Type: EvtArtifact, Agent: name, Filename: filename, Title: title}) } else { // 非文档内容(提问、简短回复等)直接显示在聊天中 r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: result}) r.lastActiveMember = name } }(memberName, task) } wg.Wait() return results } func (r *Room) runChallengeRound(ctx context.Context, board *SharedBoard, skillXML string) { var challengers []string for name, member := range r.members { if member.Config.CanChallenge { challengers = append(challengers, name) } } if len(challengers) == 0 { return } boardCtx := board.ToContext() if boardCtx == "" { return } var wg sync.WaitGroup for _, name := range challengers { wg.Add(1) go func(n string) { defer wg.Done() member := r.members[n] extraCtx := boardCtx + "\n\n" + skillXML memberSystem := member.BuildSystemPrompt(extraCtx) memberMsgs := []llm.Message{ llm.NewMsg("system", memberSystem+"\n\nReview the team board above. If you see issues or want to challenge any draft, output CHALLENGE:. Otherwise output AGREE."), llm.NewMsg("user", "Please review the team board and provide your feedback."), } var reply strings.Builder _, err := member.Chat(ctx, memberMsgs, func(token string) { reply.WriteString(token) r.emit(Event{Type: EvtAgentMessage, Agent: n, Role: "challenge", Content: token, Streaming: true}) }) if err != nil { return } r.emit(Event{Type: EvtAgentMessage, Agent: n, Role: "challenge", Content: "", Streaming: false}) result := reply.String() if strings.Contains(result, "CHALLENGE:") { board.Add(n, result, "challenge") r.AppendHistory("challenge", n, result) } }(name) } wg.Wait() } // Handle processes a user message through master orchestration. func (r *Room) Handle(ctx context.Context, userMsg string) error { return r.HandleUserMessage(ctx, "user", userMsg) } // parseUserMentions 从用户消息中提取 @agent 指派。 // 返回指派 map 和去除 @agent 后的剩余消息。 func parseUserMentions(text string, validMembers map[string]*agent.Agent) map[string]string { assignments := make(map[string]string) for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "@") { continue } rest := strings.TrimPrefix(line, "@") idx := strings.IndexAny(rest, " \t") if idx <= 0 { // 只有 @name 没有任务内容,跳过 continue } name := strings.TrimSpace(rest[:idx]) task := strings.TrimSpace(rest[idx+1:]) if _, ok := validMembers[name]; ok && task != "" { assignments[name] = task } } return assignments } // HandleUserMessage 处理用户消息。 // 如果用户消息中包含 @agent,直接将任务分配给对应 agent,不经过 master。 func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) error { if r.master == nil { return fmt.Errorf("room has no master agent configured") } r.AppendHistory("user", userName, userMsg) // 检测用户是否直接 @agent 指派任务 userAssignments := parseUserMentions(userMsg, r.members) if len(userAssignments) > 0 { return r.handleDirectAssign(ctx, userAssignments) } // Build 模式下,如果有 plan 阶段暂存的待执行任务,直接执行 if r.Mode == "build" && len(r.pendingAssignments) > 0 { log.Printf("[room %s] build 模式,执行 %d 个暂存任务", r.Config.Name, len(r.pendingAssignments)) assignments := r.pendingAssignments r.pendingAssignments = nil // 生成任务计划文档 r.planFilename = fmt.Sprintf("任务计划-%s.md", time.Now().Format("20060102-150405")) planContent := "# 任务计划\n\n## 规划\n\n" + r.pendingPlanReply + "\n" r.pendingPlanReply = "" r.saveWorkspace(r.planFilename, planContent) r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: r.planFilename, Title: "任务计划"}) skillXML := skill.ToXML(r.skillMeta) board := &SharedBoard{} r.setStatus(StatusWorking, "", "") r.runMembersParallel(ctx, assignments, board, skillXML) r.runChallengeRound(ctx, board, skillXML) r.setStatus(StatusPending, "", "") return nil } // Build 模式下,成员发出了提问,用户回复 → 转发给该成员继续对话 if r.Mode == "build" && r.lastActiveMember != "" { memberName := r.lastActiveMember member, ok := r.members[memberName] if !ok { return fmt.Errorf("member %s not found", memberName) } log.Printf("[room %s] build 模式,将用户回复转发给 %s", r.Config.Name, memberName) r.setStatus(StatusWorking, member.Config.Name, "") // 追加用户回复到成员对话历史 if r.memberConvos == nil { r.memberConvos = make(map[string][]llm.Message) } r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("user", userMsg)) // 追加沟通记录到任务计划文档 r.appendPlanLog(userName, memberName, userMsg) // 让成员继续对话 var memberReply strings.Builder _, err := member.Chat(ctx, r.memberConvos[memberName], func(token string) { memberReply.WriteString(token) }) if err != nil { r.setStatus(StatusPending, "", "") return err } result := memberReply.String() r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("assistant", result)) r.AppendHistory("member", memberName, result) // 追加成员回复到任务计划文档 r.appendPlanLog(memberName, userName, result) // 智能判断输出类型 if isDocument(result) { title := extractTitle(result) filename := fmt.Sprintf("%s-%s.md", memberName, time.Now().Format("20060102-150405")) r.saveWorkspace(filename, result) r.emit(Event{Type: EvtArtifact, Agent: memberName, Filename: filename, Title: title}) r.lastActiveMember = "" // 文档产出,对话结束 } else { r.emit(Event{Type: EvtAgentMessage, Agent: memberName, Role: "member", Content: result}) // lastActiveMember 保持不变,用户可以继续回复 } r.setStatus(StatusPending, "", "") return nil } r.setStatus(StatusThinking, "", "") // 构建 system prompt teamXML := r.buildTeamXML() skillXML := skill.ToXML(r.skillMeta) var userXML string if r.User != nil { userXML = r.User.BuildUserXML() } extraContext := userXML + "\n\n" + teamXML + "\n\n" + skillXML systemPrompt := r.master.BuildSystemPrompt(extraContext) sysMsg := llm.NewMsg("system", systemPrompt+fmt.Sprintf(` 当前用户:%s 分配任务给成员时,使用 @ 格式,每行一个: @成员名 任务描述 直接回复用户时,正常说话即可,不需要 @。`, userName)) // 使用持久化的会话历史 r.historyMu.Lock() // 始终更新 system prompt(可能 SOUL.md 改了) if len(r.masterHistory) == 0 { r.masterHistory = []llm.Message{sysMsg} } else { r.masterHistory[0] = sysMsg } r.masterHistory = append(r.masterHistory, llm.NewMsg("user", userMsg)) // 限制历史长度,保留 system + 最近 20 轮对话 if len(r.masterHistory) > 41 { r.masterHistory = append(r.masterHistory[:1], r.masterHistory[len(r.masterHistory)-40:]...) } masterMsgs := make([]llm.Message, len(r.masterHistory)) copy(masterMsgs, r.masterHistory) r.historyMu.Unlock() // Master 规划循环 for iteration := 0; iteration < 5; iteration++ { log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration) var masterReply strings.Builder _, err := r.master.Chat(ctx, masterMsgs, func(token string) { masterReply.WriteString(token) r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: token, Streaming: true}) }) if err != nil { log.Printf("[room %s] master chat error: %v", r.Config.Name, err) return err } reply := masterReply.String() log.Printf("[room %s] master reply (%d chars): %.100s...", r.Config.Name, len(reply), reply) // 发送 streaming 结束信号 r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false}) assistantMsg := llm.NewMsg("assistant", reply) masterMsgs = append(masterMsgs, assistantMsg) // 同步到持久化历史 r.historyMu.Lock() r.masterHistory = append(r.masterHistory, assistantMsg) r.historyMu.Unlock() r.AppendHistory("master", r.master.Config.Name, reply) // 解析 @ 指令,只匹配已知成员名 allMentions := parseAssignments(reply) assignments := make(map[string]string) for name, task := range allMentions { if _, isMember := r.members[name]; isMember { assignments[name] = task } } if len(assignments) == 0 { // 没有分配给任何成员的任务,master 直接回复了用户 break } // Plan 模式下不执行任务,暂存任务,提示用户切换到 Build 模式 if r.Mode != "build" { r.pendingAssignments = assignments r.pendingPlanReply = reply log.Printf("[room %s] plan 模式,暂存 %d 个任务,等待用户切换到 build 模式", r.Config.Name, len(assignments)) r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "已完成任务规划。请按 Tab 切换到 Build 模式,然后发送确认开始执行。"}) break } // 并行执行成员任务 board := &SharedBoard{} results := r.runMembersParallel(ctx, assignments, board, skillXML) // 质疑轮 r.runChallengeRound(ctx, board, skillXML) // 将结果反馈给 master 审查 r.setStatus(StatusThinking, "", "") var resultsStr strings.Builder for memberName, result := range results { resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result)) } boardCtx := board.ToContext() feedbackMsg := "Team results:\n" + resultsStr.String() if boardCtx != "" { feedbackMsg += "\n\nTeam board:\n" + boardCtx } feedbackMsg += "\n\n请审查以上结果。如果满意,直接回复总结给用户即可。如果需要修改,使用 @成员名 分配修订任务。" feedbackLLMMsg := llm.NewMsg("user", feedbackMsg) masterMsgs = append(masterMsgs, feedbackLLMMsg) // 同步反馈消息到持久化历史 r.historyMu.Lock() r.masterHistory = append(r.masterHistory, feedbackLLMMsg) r.historyMu.Unlock() // 更新任务列表 r.updateTasks(masterMsgs) } r.setStatus(StatusPending, "", "") // Auto-update master memory after task completion go r.updateMasterMemory(context.Background(), userMsg, masterMsgs) return nil } // handleDirectAssign 处理用户直接 @agent 指派的任务,跳过 master 规划。 func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]string) error { skillXML := skill.ToXML(r.skillMeta) board := &SharedBoard{} r.runMembersParallel(ctx, assignments, board, skillXML) r.runChallengeRound(ctx, board, skillXML) r.setStatus(StatusPending, "", "") return nil } func (r *Room) updateMasterMemory(ctx context.Context, task string, msgs []llm.Message) { summaryPrompt := fmt.Sprintf("Based on this task: %q\nSummarize key learnings and patterns in 3-5 bullet points for future reference. Be concise.", task) memMsgs := append(msgs, llm.NewMsg("user", summaryPrompt)) summary, err := r.master.Chat(ctx, memMsgs, nil) if err != nil || summary == "" { return } filename := time.Now().Format("2006-01") + ".md" existing, _ := os.ReadFile(filepath.Join(r.master.Dir, "memory", filename)) content := string(existing) + fmt.Sprintf("\n## %s — %s\n\n%s\n", time.Now().Format("2006-01-02"), task[:min(50, len(task))], summary) r.master.SaveMemory(filename, content) } // isDocument 判断内容是否为文档产出物(而非对话/提问)。 // 文档特征:包含 markdown 标题且内容较长。 func isDocument(content string) bool { hasHeading := strings.Contains(content, "\n# ") || strings.HasPrefix(content, "# ") return hasHeading && len([]rune(content)) > 500 } func extractTitle(content string) string { for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "# ") { return strings.TrimPrefix(line, "# ") } } return "" } func min(a, b int) int { if a < b { return a } return b } // parseAssignments 解析任务分配指令。 // 支持多行任务描述:从 @成员名 开始,到下一个 @成员名 或文本结束为止。 func parseAssignments(text string) map[string]string { result := make(map[string]string) lines := strings.Split(text, "\n") var currentName string var currentTask strings.Builder flush := func() { if currentName != "" { task := strings.TrimSpace(currentTask.String()) if task != "" { result[currentName] = task } } currentName = "" currentTask.Reset() } for _, line := range lines { trimmed := strings.TrimSpace(line) // 检测新的 @成员名 行 if strings.HasPrefix(trimmed, "@") { rest := strings.TrimPrefix(trimmed, "@") if idx := strings.IndexAny(rest, " \t"); idx > 0 { name := strings.TrimSpace(rest[:idx]) task := strings.TrimSpace(rest[idx+1:]) // 保存前一个 flush() currentName = name if task != "" { currentTask.WriteString(task) } continue } } // ASSIGN:成员名:任务描述(向后兼容) if strings.HasPrefix(trimmed, "ASSIGN:") { parts := strings.SplitN(strings.TrimPrefix(trimmed, "ASSIGN:"), ":", 2) if len(parts) == 2 { flush() result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } continue } // 当前有 @成员名 在收集中,追加后续行作为任务描述 if currentName != "" && trimmed != "" { if currentTask.Len() > 0 { currentTask.WriteString("\n") } currentTask.WriteString(trimmed) } } flush() return result } 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() } // appendPlanLog 将沟通记录追加到任务计划文档 func (r *Room) appendPlanLog(from, to, content string) { if r.planFilename == "" { return } path := filepath.Join(r.Dir, "workspace", r.planFilename) f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return } defer f.Close() entry := fmt.Sprintf("\n---\n**[%s] %s → %s**\n\n%s\n", time.Now().Format("15:04:05"), from, to, content) f.WriteString(entry) } func (r *Room) saveWorkspace(filename, content string) { dir := filepath.Join(r.Dir, "workspace") os.MkdirAll(dir, 0755) os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) } func (r *Room) updateTasks(msgs []llm.Message) { // 从对话中提取任务列表并保存 var tasks strings.Builder tasks.WriteString("# Tasks\n\n") for _, m := range msgs { if m.Role == "assistant" { assignments := parseAssignments(m.Content) for name, task := range assignments { tasks.WriteString(fmt.Sprintf("- [ ] [%s] %s\n", name, task)) } } } content := tasks.String() os.WriteFile(filepath.Join(r.Dir, "tasks.md"), []byte(content), 0644) r.emit(Event{Type: EvtTasksUpdate, Content: content}) } func parseRoomConfig(data []byte) (Config, error) { var cfg Config if !bytes.HasPrefix(data, []byte("---")) { return cfg, fmt.Errorf("missing frontmatter") } parts := bytes.SplitN(data, []byte("---"), 3) if len(parts) < 3 { return cfg, fmt.Errorf("invalid frontmatter") } return cfg, yaml.Unmarshal(parts[1], &cfg) } // resolveAgentPath finds agent dir: prefers agentsDir/team/name, falls back to agentsDir/name func resolveAgentPath(agentsDir, team, name string) string { if team != "" { p := filepath.Join(agentsDir, team, name) if _, err := os.Stat(p); err == nil { return p } } return filepath.Join(agentsDir, name) }