scorpio cbbb7d399d 集成 ToolExecutor 到 Room
- 在 Room 结构体中添加 ToolExecutor 字段
- 在 Load 函数中初始化 ToolExecutor
2026-03-10 09:37:42 +08:00

881 lines
29 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}