881 lines
29 KiB
Go
881 lines
29 KiB
Go
package room
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||
"github.com/sdaduanbilei/agent-team/internal/prompt"
|
||
"github.com/sdaduanbilei/agent-team/internal/room/tools"
|
||
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||
"github.com/sdaduanbilei/agent-team/internal/store"
|
||
)
|
||
|
||
func Load(roomDir string, agentsDir string, skillsDir string, opts ...LoadOption) (*Room, error) {
|
||
var lo loadOptions
|
||
for _, opt := range opts {
|
||
opt(&lo)
|
||
}
|
||
|
||
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", Status: StatusPending}
|
||
|
||
r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace"))
|
||
|
||
projectRoot := filepath.Dir(agentsDir)
|
||
if data, err := os.ReadFile(filepath.Join(projectRoot, "SYSTEM.md")); err == nil {
|
||
r.systemRules = string(data)
|
||
}
|
||
|
||
// 从 DB 预加载所有 agent 配置
|
||
var dbConfigs []store.AgentConfig
|
||
if lo.store != nil {
|
||
dbConfigs, _ = lo.store.GetRoomAgentConfigs(cfg.Name)
|
||
}
|
||
dbConfigMap := make(map[string]map[string]string) // agent_name -> file_type -> content
|
||
for _, c := range dbConfigs {
|
||
if dbConfigMap[c.AgentName] == nil {
|
||
dbConfigMap[c.AgentName] = make(map[string]string)
|
||
}
|
||
dbConfigMap[c.AgentName][c.FileType] = c.Content
|
||
}
|
||
|
||
if cfg.Master != "" {
|
||
var knowledgeDir string
|
||
if cfg.Team != "" {
|
||
kd := filepath.Join(agentsDir, cfg.Team, "knowledge")
|
||
if info, err := os.Stat(kd); err == nil && info.IsDir() {
|
||
knowledgeDir = kd
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
r.master.KnowledgeDir = knowledgeDir
|
||
// DB 配置覆盖
|
||
if files, ok := dbConfigMap[cfg.Master]; ok {
|
||
if soul, ok := files["soul"]; ok {
|
||
r.master.Soul = soul
|
||
r.master.UseDBConfig = true
|
||
}
|
||
if agentDoc, ok := files["agent"]; ok {
|
||
r.master.AgentDoc = agentDoc
|
||
r.master.UseDBConfig = true
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
a.KnowledgeDir = knowledgeDir
|
||
// DB 配置覆盖
|
||
if files, ok := dbConfigMap[name]; ok {
|
||
if soul, ok := files["soul"]; ok {
|
||
a.Soul = soul
|
||
a.UseDBConfig = true
|
||
}
|
||
if agentDoc, ok := files["agent"]; ok {
|
||
a.AgentDoc = agentDoc
|
||
a.UseDBConfig = true
|
||
}
|
||
}
|
||
r.members[name] = a
|
||
}
|
||
}
|
||
|
||
// 加载 TEAM.md 项目模板:优先从 DB,降级到文件系统
|
||
teamMDBody := ""
|
||
if teamDoc, ok := dbConfigMap["__team__"]["team_doc"]; ok && teamDoc != "" {
|
||
teamMDBody = teamDoc
|
||
} else if cfg.Team != "" {
|
||
teamMDPath := filepath.Join(agentsDir, cfg.Team, "TEAM.md")
|
||
if teamData, err := os.ReadFile(teamMDPath); err == nil {
|
||
teamMDBody = string(teamData)
|
||
}
|
||
}
|
||
if teamMDBody != "" {
|
||
body := teamMDBody
|
||
if strings.HasPrefix(body, "---") {
|
||
parts := strings.SplitN(body, "---", 3)
|
||
if len(parts) >= 3 {
|
||
body = parts[2]
|
||
}
|
||
}
|
||
if pt := parseProjectTemplate(body); pt != nil {
|
||
r.projectTemplate = pt
|
||
log.Printf("[room %s] 已加载项目模板,包含 %d 个文件定义", cfg.Name, len(pt.Files))
|
||
}
|
||
if wf := extractTeamWorkflow(body); wf != "" {
|
||
r.teamWorkflow = wf
|
||
log.Printf("[room %s] 已加载团队工作流描述 (%d 字符)", cfg.Name, len(wf))
|
||
}
|
||
}
|
||
|
||
r.skillMeta, _ = skill.Discover(skillsDir)
|
||
|
||
// 初始化 prompt 模板引擎
|
||
r.Prompt = prompt.New()
|
||
// 加载 room 级模板覆盖(如果存在)
|
||
roomPromptDir := filepath.Join(roomDir, "prompts")
|
||
r.Prompt.LoadOverrides(roomPromptDir)
|
||
|
||
return r, nil
|
||
}
|
||
|
||
func (r *Room) emit(e Event) {
|
||
e.RoomID = r.Config.Name
|
||
if r.Broadcast != nil {
|
||
r.Broadcast(e)
|
||
}
|
||
if r.Store != nil && !e.NoStore {
|
||
gid := &r.currentGroupID
|
||
switch e.Type {
|
||
case EvtAgentMessage:
|
||
if !e.Streaming && e.Content != "" && e.Role != "tool_use" &&
|
||
!strings.HasPrefix(e.Content, "正在处理:") && !strings.HasPrefix(e.Content, "正在处理: ") {
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: e.Agent, Role: e.Role,
|
||
Content: e.Content, GroupID: gid,
|
||
})
|
||
}
|
||
case EvtArtifact:
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: e.Agent, Role: "artifact",
|
||
Content: e.Title, Filename: e.Filename, Title: e.Title, GroupID: gid,
|
||
})
|
||
case EvtTaskAssign:
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: e.From, Role: "task_assign",
|
||
Content: e.Task, Title: e.To, GroupID: gid,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *Room) emitUsage(agentName string, usage llm.Usage) {
|
||
if usage.TotalTokens > 0 {
|
||
r.emit(Event{
|
||
Type: EvtTokenUsage,
|
||
Agent: agentName,
|
||
PromptTokens: usage.PromptTokens,
|
||
CompletionTokens: usage.CompletionTokens,
|
||
TotalTokens: usage.TotalTokens,
|
||
})
|
||
if r.Store != nil {
|
||
r.Store.InsertTokenUsage(r.Config.Name, agentName, usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens)
|
||
}
|
||
}
|
||
}
|
||
|
||
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})
|
||
}
|
||
|
||
func (r *Room) Stop() {
|
||
r.cancelMu.Lock()
|
||
defer r.cancelMu.Unlock()
|
||
if r.cancelFunc != nil {
|
||
r.cancelFunc()
|
||
r.cancelFunc = nil
|
||
}
|
||
r.setStatus(StatusPending, "", "")
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// contextMaxTokens 是模型上下文窗口限制(留 8K 给输出)
|
||
const contextMaxTokens = 120000
|
||
|
||
// estimateTokens 估算消息列表的 token 数(中文约 1.5 token/字,英文约 0.75 token/word)
|
||
func estimateTokens(msgs []llm.Message) int {
|
||
total := 0
|
||
for _, m := range msgs {
|
||
// 粗略估算:每个字符约 1 token(中英混合取均值)
|
||
total += len([]rune(m.Content))
|
||
}
|
||
return total
|
||
}
|
||
|
||
// compressMessages 当 token 数超限时,保留 system prompt + 最近的消息,中间部分压缩为摘要
|
||
func compressMessages(msgs []llm.Message, maxTokens int) []llm.Message {
|
||
if len(msgs) <= 3 {
|
||
return msgs
|
||
}
|
||
est := estimateTokens(msgs)
|
||
if est <= maxTokens {
|
||
return msgs
|
||
}
|
||
|
||
log.Printf("[context] 当前 %d tokens (估算), 超过 %d 限制, 压缩历史", est, maxTokens)
|
||
|
||
// 保留: system(第0条) + 最近的消息
|
||
system := msgs[0]
|
||
middle := msgs[1:]
|
||
|
||
// 从后往前保留消息,直到 token 数在限制内
|
||
// 留 system 的 token + 安全余量
|
||
sysTokens := len([]rune(system.Content))
|
||
budget := maxTokens - sysTokens - 2000 // 留 2K 余量
|
||
|
||
var kept []llm.Message
|
||
keptTokens := 0
|
||
for i := len(middle) - 1; i >= 0; i-- {
|
||
msgTokens := len([]rune(middle[i].Content))
|
||
if keptTokens+msgTokens > budget {
|
||
break
|
||
}
|
||
kept = append([]llm.Message{middle[i]}, kept...)
|
||
keptTokens += msgTokens
|
||
}
|
||
|
||
// 被截掉的部分生成摘要提示
|
||
droppedCount := len(middle) - len(kept)
|
||
if droppedCount > 0 {
|
||
summary := llm.NewMsg("user", fmt.Sprintf("[系统提示] 之前有 %d 条对话消息因上下文长度限制已被压缩。请基于当前可见的消息继续工作。", droppedCount))
|
||
// NOTE: compressMessages 是无状态函数,无法访问 r.Prompt,保留 fmt 拼接
|
||
result := make([]llm.Message, 0, 2+len(kept))
|
||
result = append(result, system, summary)
|
||
result = append(result, kept...)
|
||
log.Printf("[context] 压缩完成: 保留 %d 条, 丢弃 %d 条, 估算 %d tokens", len(kept), droppedCount, estimateTokens(result))
|
||
return result
|
||
}
|
||
return msgs
|
||
}
|
||
|
||
// 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 处理用户消息。
|
||
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)
|
||
|
||
if r.Store != nil {
|
||
id, _ := r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: userName, Role: "user", Content: userMsg,
|
||
})
|
||
r.currentGroupID = id
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
r.cancelMu.Lock()
|
||
r.cancelFunc = cancel
|
||
r.cancelMu.Unlock()
|
||
defer func() {
|
||
r.cancelMu.Lock()
|
||
r.cancelFunc = nil
|
||
r.cancelMu.Unlock()
|
||
}()
|
||
|
||
// 检测用户直接 @master:去掉前缀,作为普通对话处理
|
||
if r.master != nil {
|
||
masterName := r.master.Config.Name
|
||
prefix := "@" + masterName
|
||
trimmed := strings.TrimSpace(userMsg)
|
||
if strings.HasPrefix(trimmed, prefix) {
|
||
question := strings.TrimSpace(strings.TrimPrefix(trimmed, prefix))
|
||
if question != "" {
|
||
// 清除 lastActiveMember,确保消息发给 master 而非成员
|
||
r.lastActiveMember = ""
|
||
userMsg = question
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检测用户直接 @agent(成员)
|
||
userAssignments := parseUserMentions(userMsg, r.members)
|
||
if len(userAssignments) > 0 {
|
||
// 区分对话和任务分配:短消息/问候视为对话
|
||
if len(userAssignments) == 1 {
|
||
for name, task := range userAssignments {
|
||
if isConversational(task) {
|
||
r.lastActiveMember = name
|
||
return r.handleMemberConversation(ctx, userName, task)
|
||
}
|
||
}
|
||
}
|
||
return r.handleDirectAssign(ctx, userAssignments)
|
||
}
|
||
|
||
// Build 模式下执行暂存任务
|
||
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.pendingPlanReply = ""
|
||
|
||
skillXML := skill.ToXML(r.skillMeta)
|
||
board := &SharedBoard{}
|
||
r.setStatus(StatusWorking, "", "")
|
||
r.runMembersParallel(ctx, assignments, board, skillXML)
|
||
// 只在写作阶段(前置材料全部完成后)触发审读
|
||
if r.allStaticFilesDone() {
|
||
r.runChallengeRound(ctx, board, skillXML)
|
||
}
|
||
|
||
r.setStatus(StatusPending, "", "")
|
||
return nil
|
||
}
|
||
|
||
// Build 模式下成员对话续接
|
||
if r.Mode == "build" && r.lastActiveMember != "" {
|
||
return r.handleMemberConversation(ctx, userName, userMsg)
|
||
}
|
||
|
||
r.setStatus(StatusThinking, "", "")
|
||
|
||
// 构建 system prompt
|
||
teamXML := r.buildTeamXML()
|
||
skillXML := skill.ToXML(r.skillMeta)
|
||
skillSummary := r.buildSkillSummary()
|
||
var userXML string
|
||
if r.User != nil {
|
||
userXML = r.User.BuildUserXML()
|
||
}
|
||
extraContext := userXML + "\n\n" + teamXML + "\n\n" + skillSummary
|
||
if r.systemRules != "" {
|
||
extraContext = r.systemRules + "\n\n" + extraContext
|
||
}
|
||
if r.teamWorkflow != "" {
|
||
extraContext = extraContext + "\n\n<team_workflow>\n" + r.teamWorkflow + "\n</team_workflow>"
|
||
}
|
||
if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" {
|
||
extraContext = extraContext + "\n\n" + projectCtx
|
||
}
|
||
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||
extraContext = extraContext + "\n\n" + wsCtx
|
||
}
|
||
systemPrompt := r.master.BuildSystemPrompt(extraContext)
|
||
modeInfo := fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode)
|
||
if r.Mode == "build" {
|
||
modeInfo += "\n\n" + r.Prompt.R("mode_build_rule")
|
||
} else {
|
||
modeInfo += "\n\n" + r.Prompt.R("mode_plan_rule")
|
||
}
|
||
sysMsg := llm.NewMsg("system", systemPrompt+modeInfo)
|
||
|
||
r.historyMu.Lock()
|
||
if len(r.masterHistory) == 0 {
|
||
r.masterHistory = []llm.Message{sysMsg}
|
||
} else {
|
||
r.masterHistory[0] = sysMsg
|
||
}
|
||
r.masterHistory = append(r.masterHistory, llm.NewMsg("user", userMsg))
|
||
// token 感知的历史压缩(替代简单的消息数截断)
|
||
r.masterHistory = compressMessages(r.masterHistory, contextMaxTokens)
|
||
masterMsgs := make([]llm.Message, len(r.masterHistory))
|
||
copy(masterMsgs, r.masterHistory)
|
||
r.historyMu.Unlock()
|
||
|
||
// Master 规划循环:简化为最多 3 轮(安全上限),每次用户消息通常只跑 1 轮
|
||
const maxIterations = 3
|
||
for iteration := 0; iteration < maxIterations; iteration++ {
|
||
log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration)
|
||
|
||
if r.projectTemplate != nil {
|
||
if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration) {
|
||
break
|
||
}
|
||
} else {
|
||
if !r.masterLegacyIteration(ctx, &masterMsgs, skillXML, iteration) {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
r.setStatus(StatusPending, "", "")
|
||
go r.updateMasterMemory(context.Background(), userMsg, masterMsgs)
|
||
return nil
|
||
}
|
||
|
||
// masterCallerDecidedIteration 执行一次 Caller-Decided 路径的 master 迭代。
|
||
// 返回 true 表示继续循环,false 表示 break。
|
||
func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int) bool {
|
||
if iteration > 0 {
|
||
r.setStatus(StatusThinking, r.master.Config.Name, "正在规划...")
|
||
}
|
||
|
||
// 调用前压缩 context
|
||
*masterMsgs = compressMessages(*masterMsgs, contextMaxTokens)
|
||
|
||
// CHAT CALL: 纯规划+分配
|
||
var masterReply strings.Builder
|
||
_, usage, err := r.master.ChatWithUsage(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 false
|
||
}
|
||
r.emitUsage(r.master.Config.Name, usage)
|
||
reply := masterReply.String()
|
||
log.Printf("[room %s] master chat reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
||
|
||
// 安全网:如果 master 的聊天回复中混入了文档正文,剥离并替换显示
|
||
persistContent := reply
|
||
if r.projectTemplate != nil && isDocument(reply) {
|
||
stripped := r.stripDocuments(reply)
|
||
if stripped != "" && stripped != reply {
|
||
persistContent = stripped
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: stripped, Streaming: false, Action: "replace"})
|
||
log.Printf("[room %s] master 聊天中混入文档内容,已剥离", r.Config.Name)
|
||
} else {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||
}
|
||
} else {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||
}
|
||
|
||
// 存为 text part
|
||
if r.Store != nil {
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||
Content: persistContent, PartType: "text",
|
||
GroupID: &r.currentGroupID,
|
||
})
|
||
}
|
||
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)
|
||
|
||
// Plan 模式下:master 只允许聊天,禁止一切文件操作和任务分配
|
||
if r.Mode != "build" {
|
||
// 检查是否有 @分配 → 提醒用户切换模式
|
||
allMentions := parseAssignments(reply)
|
||
hasAssignment := false
|
||
for name := range allMentions {
|
||
if _, isMember := r.members[name]; isMember {
|
||
hasAssignment = true
|
||
break
|
||
}
|
||
}
|
||
if hasAssignment {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master",
|
||
Content: r.Prompt.R("plan_mode_blocked")})
|
||
}
|
||
return false // plan 模式下每次只回复一条,不循环
|
||
}
|
||
|
||
// 增量编辑(仅 build 模式)
|
||
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
||
editTitle := strings.TrimSuffix(editFile, ".md")
|
||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: editFile, Title: editTitle})
|
||
if r.Store != nil {
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||
Content: editTitle, Filename: editFile, PartType: "document",
|
||
GroupID: &r.currentGroupID,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 安全网:如果 master 在聊天中直接输出了文档内容,自动拦截保存
|
||
if isDocument(reply) && r.allStaticFilesDone() {
|
||
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
||
chapterFilename := r.extractChapterFilename(reply, dynDir)
|
||
content := strings.TrimSpace(reply)
|
||
if !strings.HasPrefix(content, "# ") {
|
||
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
||
}
|
||
r.saveWorkspace(chapterFilename, content)
|
||
docName := strings.TrimSuffix(chapterFilename, ".md")
|
||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, 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: chapterFilename, PartType: "document",
|
||
GroupID: &r.currentGroupID,
|
||
})
|
||
}
|
||
log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, chapterFilename)
|
||
}
|
||
}
|
||
|
||
// 解析 @ 分配
|
||
allMentions := parseAssignments(reply)
|
||
assignments := make(map[string]string)
|
||
for name, task := range allMentions {
|
||
if _, isMember := r.members[name]; isMember {
|
||
assignments[name] = task
|
||
}
|
||
}
|
||
|
||
// Phase 轻量校验:只在分配写手(phase:2 任务)时检查前置材料是否就绪
|
||
if len(assignments) > 0 && r.projectTemplate != nil {
|
||
blocked := r.validatePhaseAssignments(assignments)
|
||
if len(blocked) > 0 {
|
||
var blockedList strings.Builder
|
||
for name, reason := range blocked {
|
||
blockedList.WriteString(fmt.Sprintf("- @%s: %s\n", name, reason))
|
||
delete(assignments, name)
|
||
}
|
||
blockedContent := r.Prompt.Render("phase_blocked", map[string]string{
|
||
"BlockedList": blockedList.String(),
|
||
})
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master", Content: blockedContent})
|
||
log.Printf("[room %s] phase 校验阻止了 %d 个任务分配", r.Config.Name, len(blocked))
|
||
|
||
// 注入反馈让 master 知道被阻止了
|
||
phaseBlockMsg := llm.NewMsg("user", blockedContent)
|
||
*masterMsgs = append(*masterMsgs, phaseBlockMsg)
|
||
r.historyMu.Lock()
|
||
r.masterHistory = append(r.masterHistory, phaseBlockMsg)
|
||
r.historyMu.Unlock()
|
||
}
|
||
}
|
||
|
||
if len(assignments) > 0 && r.Mode != "build" {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
||
Content: r.Prompt.R("plan_mode_blocked")})
|
||
return false
|
||
}
|
||
|
||
// 执行成员任务
|
||
if len(assignments) > 0 && r.Mode == "build" {
|
||
board := &SharedBoard{}
|
||
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
||
// 只在写作阶段(前置材料全部完成后)触发审读
|
||
if r.allStaticFilesDone() {
|
||
r.runChallengeRound(ctx, board, skillXML)
|
||
}
|
||
|
||
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
||
var resultsStr strings.Builder
|
||
for memberName, result := range results {
|
||
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
||
}
|
||
feedbackMsg := r.Prompt.Render("feedback_results", map[string]string{
|
||
"Results": resultsStr.String(),
|
||
"BoardContext": board.ToContext(),
|
||
"WorkspaceContext": r.buildWorkspaceContext(),
|
||
"WorkflowStep": r.buildWorkflowStep(),
|
||
})
|
||
|
||
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
||
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
||
r.historyMu.Lock()
|
||
r.masterHistory = append(r.masterHistory, feedbackLLMMsg)
|
||
r.historyMu.Unlock()
|
||
r.updateTasks(*masterMsgs)
|
||
}
|
||
|
||
// FILE UPDATE CALLS: master 提到要更新已存在的文件
|
||
if r.Mode == "build" {
|
||
updateFiles := r.parseMasterUpdateIntent(reply)
|
||
if len(updateFiles) > 0 {
|
||
for _, file := range updateFiles {
|
||
r.masterFileUpdateCall(ctx, masterMsgs, file, reply)
|
||
}
|
||
}
|
||
}
|
||
|
||
// FILE CALLS: master 负责的文件
|
||
pendingFiles := r.findPendingMasterFiles()
|
||
if len(pendingFiles) > 0 && r.Mode == "build" {
|
||
for _, file := range pendingFiles {
|
||
r.masterFileCall(ctx, masterMsgs, file)
|
||
}
|
||
} else if r.Mode == "build" && r.allStaticFilesDone() {
|
||
// 所有静态文件完成,检查是否有动态章节需要写
|
||
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
||
// 用 master 的聊天回复作为章节规划提示
|
||
r.masterChapterFileCall(ctx, masterMsgs, dynDir, reply)
|
||
}
|
||
}
|
||
|
||
// 每次用户消息最多执行一轮分配 + 一次 file call,然后停下等用户
|
||
return false
|
||
}
|
||
|
||
// masterLegacyIteration 执行一次旧路径的 master 迭代(无项目模板)。
|
||
func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int) bool {
|
||
if iteration > 0 {
|
||
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写文档...")
|
||
}
|
||
// 调用前压缩 context
|
||
*masterMsgs = compressMessages(*masterMsgs, contextMaxTokens)
|
||
|
||
var masterReply strings.Builder
|
||
_, usage, err := r.master.ChatWithUsage(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 false
|
||
}
|
||
r.emitUsage(r.master.Config.Name, usage)
|
||
reply := masterReply.String()
|
||
log.Printf("[room %s] master reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
||
|
||
// Plan 模式下:只聊天,禁止一切后续操作
|
||
if r.Mode != "build" {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||
if r.Store != nil {
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||
Content: reply, PartType: "text",
|
||
GroupID: &r.currentGroupID,
|
||
})
|
||
}
|
||
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)
|
||
hasAssignment := false
|
||
for name := range allMentions {
|
||
if _, isMember := r.members[name]; isMember {
|
||
hasAssignment = true
|
||
break
|
||
}
|
||
}
|
||
if hasAssignment {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master",
|
||
Content: r.Prompt.R("plan_mode_blocked")})
|
||
}
|
||
return false
|
||
}
|
||
|
||
var savedDocTitles []string
|
||
persistContent := reply
|
||
|
||
// Build 模式下执行文件产出
|
||
{
|
||
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
||
persistContent = fmt.Sprintf("已更新《%s》", strings.TrimSuffix(editFile, ".md"))
|
||
savedDocTitles = append(savedDocTitles, strings.TrimSuffix(editFile, ".md"))
|
||
} else if docs := splitDocuments(reply); len(docs) > 0 {
|
||
for _, doc := range docs {
|
||
title := extractTitle(doc)
|
||
filename := titleToFilename(title, r.master.Config.Name)
|
||
r.saveWorkspace(filename, doc)
|
||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: filename, Title: title})
|
||
savedDocTitles = append(savedDocTitles, title)
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(savedDocTitles) > 0 {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: persistContent, Streaming: false, Action: "replace"})
|
||
} else {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||
}
|
||
|
||
if r.Store != nil && persistContent != "" {
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||
Content: persistContent, PartType: "text",
|
||
GroupID: &r.currentGroupID,
|
||
})
|
||
}
|
||
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 {
|
||
if len(savedDocTitles) > 0 && r.Mode == "build" {
|
||
continueMsg := r.Prompt.R("continue_legacy")
|
||
continueLLMMsg := llm.NewMsg("user", continueMsg)
|
||
*masterMsgs = append(*masterMsgs, continueLLMMsg)
|
||
r.historyMu.Lock()
|
||
r.masterHistory = append(r.masterHistory, continueLLMMsg)
|
||
r.historyMu.Unlock()
|
||
return true // continue
|
||
}
|
||
return false // break
|
||
}
|
||
|
||
if r.Mode != "build" {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
||
Content: r.Prompt.R("plan_mode_blocked")})
|
||
return false
|
||
}
|
||
|
||
board := &SharedBoard{}
|
||
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
||
// 只在写作阶段(前置材料全部完成后)触发审读
|
||
if r.allStaticFilesDone() {
|
||
r.runChallengeRound(ctx, board, skillXML)
|
||
}
|
||
|
||
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
||
var resultsStr strings.Builder
|
||
for memberName, result := range results {
|
||
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
||
}
|
||
var wsFilesList string
|
||
if wsFiles := r.listWorkspaceFiles(); len(wsFiles) > 0 {
|
||
wsFilesList = "\n\n当前产出物文件:\n"
|
||
for _, f := range wsFiles {
|
||
wsFilesList += "- " + f + "\n"
|
||
}
|
||
}
|
||
feedbackMsg := r.Prompt.Render("feedback_legacy", map[string]string{
|
||
"Results": resultsStr.String(),
|
||
"WorkspaceFiles": wsFilesList,
|
||
})
|
||
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
||
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
||
r.historyMu.Lock()
|
||
r.masterHistory = append(r.masterHistory, feedbackLLMMsg)
|
||
r.historyMu.Unlock()
|
||
r.updateTasks(*masterMsgs)
|
||
return true // continue
|
||
}
|
||
|
||
// handleMemberConversation 处理用户与成员的对话续接
|
||
func (r *Room) handleMemberConversation(ctx context.Context, userName, userMsg string) error {
|
||
memberName := r.lastActiveMember
|
||
member, ok := r.members[memberName]
|
||
if !ok {
|
||
return fmt.Errorf("member %s not found", memberName)
|
||
}
|
||
log.Printf("[room %s] 用户与 %s 对话", r.Config.Name, memberName)
|
||
r.setStatus(StatusWorking, member.Config.Name, "")
|
||
|
||
if r.memberConvos == nil {
|
||
r.memberConvos = make(map[string][]llm.Message)
|
||
}
|
||
|
||
// 如果该成员没有现有对话上下文,初始化系统提示
|
||
if len(r.memberConvos[memberName]) == 0 {
|
||
extraCtx := r.buildTeamXML()
|
||
if r.systemRules != "" {
|
||
extraCtx = r.systemRules + "\n\n" + extraCtx
|
||
}
|
||
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||
extraCtx += "\n\n" + wsCtx
|
||
}
|
||
systemPrompt := member.BuildSystemPrompt(extraCtx)
|
||
r.memberConvos[memberName] = []llm.Message{
|
||
llm.NewMsg("system", systemPrompt+"\n\n"+r.Prompt.R("member_conversation")),
|
||
}
|
||
}
|
||
|
||
r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("user", userMsg))
|
||
|
||
var memberReply strings.Builder
|
||
_, usage, err := member.ChatWithUsage(ctx, r.memberConvos[memberName], func(token string) {
|
||
memberReply.WriteString(token)
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: memberName, Role: "member", Content: token, Streaming: true})
|
||
})
|
||
if err != nil {
|
||
r.setStatus(StatusPending, "", "")
|
||
return err
|
||
}
|
||
r.emitUsage(memberName, usage)
|
||
result := memberReply.String()
|
||
|
||
// 结束流式
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: memberName, Role: "member", Content: "", Streaming: false})
|
||
|
||
r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("assistant", result))
|
||
r.AppendHistory("member", memberName, result)
|
||
|
||
// 存储到数据库
|
||
if r.Store != nil && strings.TrimSpace(result) != "" {
|
||
r.Store.InsertMessage(&store.Message{
|
||
RoomID: r.Config.Name, Agent: memberName, Role: "member",
|
||
Content: result, PartType: "text",
|
||
GroupID: &r.currentGroupID,
|
||
})
|
||
}
|
||
|
||
_, routed := r.saveAgentOutput(memberName, result, "")
|
||
if routed {
|
||
r.lastActiveMember = ""
|
||
} else if !isDocument(result) {
|
||
// lastActiveMember 保持不变,用户可继续对话
|
||
} else {
|
||
r.lastActiveMember = ""
|
||
}
|
||
|
||
r.setStatus(StatusPending, "", "")
|
||
return nil
|
||
}
|
||
|
||
// handleDirectAssign 处理用户直接 @agent 指派的任务
|
||
func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]string) error {
|
||
// Plan 模式下阻止任务执行
|
||
if r.Mode != "build" {
|
||
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master",
|
||
Content: r.Prompt.R("plan_mode_blocked")})
|
||
r.setStatus(StatusPending, "", "")
|
||
return nil
|
||
}
|
||
|
||
skillXML := skill.ToXML(r.skillMeta)
|
||
board := &SharedBoard{}
|
||
|
||
r.runMembersParallel(ctx, assignments, board, skillXML)
|
||
// 只在写作阶段(前置材料全部完成后)触发审读
|
||
if r.allStaticFilesDone() {
|
||
r.runChallengeRound(ctx, board, skillXML)
|
||
}
|
||
|
||
r.setStatus(StatusPending, "", "")
|
||
return nil
|
||
}
|