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)
}