2026-03-06 13:32:23 +08:00

456 lines
13 KiB
Go

package room
import (
"bytes"
"context"
"fmt"
"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 (
TypeDept RoomType = "dept"
TypeLeader RoomType = "leader"
)
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
}
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"
)
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"`
}
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("<team_board>\n")
for _, e := range b.entries {
fmt.Fprintf(&sb, " <entry type=\"%s\" author=\"%s\">\n%s\n </entry>\n", e.Type, e.Author, e.Content)
}
sb.WriteString("</team_board>")
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)}
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) 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})
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)
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: token, Streaming: true})
})
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 strings.Contains(result, "# ") {
filename := fmt.Sprintf("%s-%s.md", name, time.Now().Format("20060102-150405"))
r.saveWorkspace(filename, result)
r.emit(Event{Type: EvtWorkspaceFile, Filename: filename, Content: result})
}
}(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:<your concern>. 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
}
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)
}
// HandleUserMessage processes a user message with a specific user name.
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)
r.setStatus(StatusThinking, "", "")
// Build master context
teamXML := r.buildTeamXML()
skillXML := skill.ToXML(r.skillMeta)
// Build user info XML
var userXML string
if r.User != nil {
userXML = r.User.BuildUserXML()
}
extraContext := userXML + "\n\n" + teamXML + "\n\n" + skillXML
systemPrompt := r.master.BuildSystemPrompt(extraContext)
masterMsgs := []llm.Message{
llm.NewMsg("system", systemPrompt+"\n\nYou are the master of this team. When you need a team member to do something, output a line like: ASSIGN:<member_name>:<task description>. When you are done reviewing and satisfied, output DONE:<summary>."),
llm.NewMsg("user", userMsg),
}
// Master planning loop
for iteration := 0; iteration < 5; 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 {
return err
}
reply := masterReply.String()
masterMsgs = append(masterMsgs, llm.NewMsg("assistant", reply))
r.AppendHistory("master", r.master.Config.Name, reply)
// Parse assignments
assignments := parseAssignments(reply)
if len(assignments) == 0 {
// No assignments, master is done
break
}
// Execute assignments in parallel
board := &SharedBoard{}
results := r.runMembersParallel(ctx, assignments, board, skillXML)
// Run challenge round
r.runChallengeRound(ctx, board, skillXML)
// Feed results back to master for review
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\nPlease review. If satisfied output DONE:<summary>, otherwise output ASSIGN instructions for revisions."
masterMsgs = append(masterMsgs, llm.NewMsg("user", feedbackMsg))
// Update tasks
r.updateTasks(masterMsgs)
if strings.Contains(reply, "DONE:") {
break
}
}
r.setStatus(StatusPending, "", "")
// Auto-update master memory after task completion
go r.updateMasterMemory(context.Background(), userMsg, masterMsgs)
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)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func parseAssignments(text string) map[string]string {
result := make(map[string]string)
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "ASSIGN:") {
parts := strings.SplitN(strings.TrimPrefix(line, "ASSIGN:"), ":", 2)
if len(parts) == 2 {
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
return result
}
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()
}
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) {
// Extract task list from conversation and save
var tasks strings.Builder
tasks.WriteString("# Tasks\n\n")
for _, m := range msgs {
if m.Role == "assistant" && strings.Contains(m.Content, "ASSIGN:") {
for _, line := range strings.Split(m.Content, "\n") {
if strings.HasPrefix(line, "ASSIGN:") {
parts := strings.SplitN(strings.TrimPrefix(line, "ASSIGN:"), ":", 2)
if len(parts) == 2 {
tasks.WriteString(fmt.Sprintf("- [ ] [%s] %s\n", strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])))
}
}
}
}
}
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)
}