- 新增 Plan/Build 模式切换(Tab 键),Plan 模式阻止任务执行 - Build 模式:成员输出智能判断,文档存 artifact,提问显示聊天 - 成员可直接与用户对话(多轮),不经过 master 传话 - 任务计划文档自动生成,沟通记录自动追加 - 右侧面板重构为产出物面板,支持查看/编辑/保存 - 输入框改为 textarea,支持 Shift+Enter 换行,修复输入法 Enter 误发送 - Master 会话历史持久化,支持多轮上下文 - parseAssignments 支持多行任务描述 - SOUL.md 热重载、skill 递归发现与内容注入 - 需求确认 skill(HARD-GATE 模式) - air 热重载配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
738 lines
23 KiB
Go
738 lines
23 KiB
Go
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("<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), 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:<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
|
||
}
|
||
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("<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()
|
||
}
|
||
|
||
// 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)
|
||
}
|