package room import ( "bytes" "context" "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "regexp" "strconv" "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/store" "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 // 成员名 -> 多轮对话历史 memberArtifacts map[string]string // 成员名 -> 已产出的文档文件名(用于后续覆盖更新) lastActiveMember string // 最后一个发出提问的成员 planFilename string // 当前任务计划文件名 systemRules string // SYSTEM.md 全局规则 projectTemplate *ProjectTemplate // 项目模板(可为 nil) Store *store.Store currentGroupID int64 // 当前用户消息的 group_id cancelFunc context.CancelFunc cancelMu sync.Mutex } 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" EvtTaskDone EventType = "task_done" EvtScheduleRun EventType = "schedule_run" EvtTokenUsage EventType = "token_usage" EvtFileRead EventType = "file_read" ) 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"` PromptTokens int `json:"prompt_tokens,omitempty"` CompletionTokens int `json:"completion_tokens,omitempty"` TotalTokens int `json:"total_tokens,omitempty"` } // ProjectFile 项目模板中的单个文件 type ProjectFile struct { Path string // 如 "创作需求书.md" Owner string // 负责的 agent 名 Phase int // 阶段编号 IsDir bool // 是否为目录 Dynamic bool // ... 标记,可动态扩展 } // ProjectTemplate 从 TEAM.md 解析的项目模板 type ProjectTemplate struct { Files []ProjectFile } // parseProjectTemplate 从 TEAM.md body 中提取 project-template 代码块并解析 func parseProjectTemplate(body string) *ProjectTemplate { // 提取 ```project-template ... ``` 代码块 re := regexp.MustCompile("(?s)```project-template\\s*\\n(.+?)```") match := re.FindStringSubmatch(body) if match == nil { return nil } block := match[1] // 逐行解析 lineRe := regexp.MustCompile(`[├└─│\s]+(.+?)\.md\s+@(\S+)\s*(?:phase:(\d+))?`) dirRe := regexp.MustCompile(`[├└─│\s]+(.+?)/\s*$`) dynamicRe := regexp.MustCompile(`[├└─│\s]+\.\.\.\s+@(\S+)\s*(?:phase:(\d+))?`) var files []ProjectFile for _, line := range strings.Split(block, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "workspace/") { continue } // 动态文件(...) if m := dynamicRe.FindStringSubmatch(line); m != nil { phase := 0 if m[2] != "" { phase, _ = strconv.Atoi(m[2]) } files = append(files, ProjectFile{ Path: "...", Owner: m[1], Phase: phase, Dynamic: true, }) continue } // 目录 if m := dirRe.FindStringSubmatch(line); m != nil { files = append(files, ProjectFile{ Path: m[1] + "/", IsDir: true, }) continue } // 普通文件 if m := lineRe.FindStringSubmatch(line); m != nil { phase := 0 if m[3] != "" { phase, _ = strconv.Atoi(m[3]) } files = append(files, ProjectFile{ Path: strings.TrimSpace(m[1]) + ".md", Owner: m[2], Phase: phase, }) } } if len(files) == 0 { return nil } return &ProjectTemplate{Files: files} } type BoardEntry struct { Author string Content string Type string // "draft" | "challenge" } type SharedBoard struct { mu sync.RWMutex entries []BoardEntry } func (b *SharedBoard) Add(author, content, typ string) { b.mu.Lock() defer b.mu.Unlock() b.entries = append(b.entries, BoardEntry{Author: author, Content: content, Type: typ}) } func (b *SharedBoard) ToContext() string { b.mu.RLock() defer b.mu.RUnlock() if len(b.entries) == 0 { return "" } var sb strings.Builder sb.WriteString("\n") for _, e := range b.entries { fmt.Fprintf(&sb, " \n%s\n \n", e.Type, e.Author, e.Content) } sb.WriteString("") return sb.String() } func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) { data, err := os.ReadFile(filepath.Join(roomDir, "room.md")) if err != nil { return nil, err } cfg, err := parseRoomConfig(data) if err != nil { return nil, err } r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan", Status: StatusPending} // 读取全局系统规则(项目根目录的 SYSTEM.md) // agentsDir 的父目录就是项目根目录 projectRoot := filepath.Dir(agentsDir) if data, err := os.ReadFile(filepath.Join(projectRoot, "SYSTEM.md")); err == nil { r.systemRules = string(data) } 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 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 r.members[name] = a } } // 加载项目模板(如果有 TEAM.md) if cfg.Team != "" { teamMDPath := filepath.Join(agentsDir, cfg.Team, "TEAM.md") if teamData, err := os.ReadFile(teamMDPath); err == nil { // 跳过 frontmatter,提取 body body := string(teamData) 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)) } } } 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) } // 持久化非 streaming 消息到 DB if r.Store != nil { gid := &r.currentGroupID switch e.Type { case EvtAgentMessage: // 跳过 streaming、空内容、临时状态消息、tool_use 状态 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) } 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 // 构建 tool 定义 tools := skill.ToTools(r.skillMeta) 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() // 有 tools 时不注入 skill XML(避免 LLM 用 XML 格式而不是 function calling) var extraCtx string if len(tools) == 0 { extraCtx = skillXML } if boardCtx != "" { if extraCtx != "" { extraCtx = boardCtx + "\n\n" + extraCtx } else { extraCtx = boardCtx } } // 注入团队成员列表,让成员知道队友是谁 teamCtx := r.buildTeamXML() if teamCtx != "" { if extraCtx != "" { extraCtx = teamCtx + "\n\n" + extraCtx } else { extraCtx = teamCtx } } if r.systemRules != "" { if extraCtx != "" { extraCtx = r.systemRules + "\n\n" + extraCtx } else { extraCtx = r.systemRules } } // 注入项目模板上下文 if projectCtx := r.buildProjectContext(name); projectCtx != "" { if extraCtx != "" { extraCtx = extraCtx + "\n\n" + projectCtx } else { extraCtx = projectCtx } } memberSystem := member.BuildSystemPrompt(extraCtx) // 将 workspace 文件内容注入任务消息,让成员能看到已有文档 taskMsg := t if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { taskMsg = t + "\n\n" + wsCtx } // 如果该成员已有文档产出,提示在原文档基础上修改 if r.memberArtifacts != nil { if _, hasDoc := r.memberArtifacts[name]; hasDoc { taskMsg += "\n\n你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。" } } // 标记该成员已查阅所有 workspace 文件,更新状态 wsFiles := r.listWorkspaceFiles() for i, f := range wsFiles { r.emit(Event{Type: EvtFileRead, Agent: name, Filename: f}) if i == 0 { r.setStatus(StatusWorking, name, fmt.Sprintf("正在阅读 %s ...", f)) } } memberMsgs := []llm.Message{ llm.NewMsg("system", memberSystem), llm.NewMsg("user", taskMsg), } // tool calling 循环:最多 10 轮 var finalReply string for round := 0; round < 10; round++ { result, err := member.ChatWithTools(ctx, memberMsgs, tools, nil) if err != nil { mu.Lock() results[name] = fmt.Sprintf("[error] %v", err) mu.Unlock() return } r.emitUsage(name, result.Usage) // 没有 tool calls,正常结束 if len(result.ToolCalls) == 0 { finalReply = result.Content break } // 有 tool calls,执行并将结果反馈 // 先把 assistant 的 tool_call 消息加入历史 assistantMsg := llm.Message{ Role: "assistant", Content: result.Content, ToolCalls: result.ToolCalls, } memberMsgs = append(memberMsgs, assistantMsg) // 发一条 tool_use 状态消息(带 streaming 标记,前端显示动画) var toolNames []string for _, tc := range result.ToolCalls { toolNames = append(toolNames, tc.Function.Name) } r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: strings.Join(toolNames, ", "), Streaming: true}) for _, tc := range result.ToolCalls { toolResult := r.executeToolCall(tc) memberMsgs = append(memberMsgs, llm.NewToolResultMsg(tc.ID, toolResult)) } // 搜索完成,进入整理阶段 r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: "thinking", Streaming: true}) } // 关闭 tool_use 状态 r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: "", Streaming: false}) // tool calling 循环耗尽后仍无文本回复,强制无 tools 调用生成总结 if finalReply == "" { memberMsgs = append(memberMsgs, llm.NewMsg("user", "请根据以上所有工具调用结果,直接输出完整的任务回复。不要再调用任何工具。")) result, err := member.ChatWithTools(ctx, memberMsgs, nil, nil) if err == nil && result.Content != "" { finalReply = result.Content r.emitUsage(name, result.Usage) } } if finalReply == "" { finalReply = "[任务执行完成,但未产生文本回复]" } 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", finalReply)) // 文件保存路由:模板路由成功时,生成简短交流消息 savedPath, routed := r.saveAgentOutput(name, finalReply, t) if routed { // 文档已保存 → 存 document part 到数据库 if r.Store != nil { title := extractTitle(finalReply) r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: name, Role: "member", Content: title, Filename: savedPath, PartType: "document", GroupID: &r.currentGroupID, }) } // 生成简短交流消息 → 存 text part briefMsg := r.generateBriefMessage(ctx, member, name, finalReply, t) if briefMsg != "" { r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: briefMsg}) if r.Store != nil { r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: name, Role: "member", Content: briefMsg, PartType: "text", GroupID: &r.currentGroupID, }) } } mu.Lock() results[name] = briefMsg mu.Unlock() r.AppendHistory("member", name, finalReply) if strings.TrimSpace(briefMsg) != "" { board.Add(name, briefMsg, "draft") } } else { mu.Lock() results[name] = finalReply mu.Unlock() r.AppendHistory("member", name, finalReply) if strings.TrimSpace(finalReply) != "" { board.Add(name, finalReply, "draft") } } // 异步保存成员记忆 go r.updateMemberMemory(context.Background(), member, t, finalReply) }(memberName, task) } wg.Wait() // 检查成员结果中是否有 @其他成员 的委派请求,自动分派第二轮 secondRound := make(map[string]string) for _, result := range results { subAssignments := parseAssignments(result) for name, task := range subAssignments { if _, isMember := r.members[name]; isMember { // 避免重复:本轮已执行过的成员不再分派 if _, alreadyDone := results[name]; !alreadyDone { secondRound[name] = task } } } } if len(secondRound) > 0 { log.Printf("[room %s] 检测到成员间委派,执行第二轮: %v", r.Config.Name, secondRound) var wg2 sync.WaitGroup for memberName, task := range secondRound { wg2.Add(1) go func(name, t string) { defer wg2.Done() member, ok := r.members[name] if !ok { return } r.setStatus(StatusWorking, member.Config.Name, t) r.emit(Event{Type: EvtTaskAssign, From: "team", To: name, Task: t}) boardCtx := board.ToContext() var extraCtx string if len(tools) == 0 { extraCtx = skillXML } if boardCtx != "" { if extraCtx != "" { extraCtx = boardCtx + "\n\n" + extraCtx } else { extraCtx = boardCtx } } // 注入团队成员列表 teamCtx := r.buildTeamXML() if teamCtx != "" { if extraCtx != "" { extraCtx = teamCtx + "\n\n" + extraCtx } else { extraCtx = teamCtx } } if r.systemRules != "" { if extraCtx != "" { extraCtx = r.systemRules + "\n\n" + extraCtx } else { extraCtx = r.systemRules } } // 注入项目模板上下文 if projectCtx := r.buildProjectContext(name); projectCtx != "" { if extraCtx != "" { extraCtx = extraCtx + "\n\n" + projectCtx } else { extraCtx = projectCtx } } memberSystem := member.BuildSystemPrompt(extraCtx) taskMsg := t if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { taskMsg = t + "\n\n" + wsCtx } // 如果该成员已有文档产出,提示在原文档基础上修改 if r.memberArtifacts != nil { if _, hasDoc := r.memberArtifacts[name]; hasDoc { taskMsg += "\n\n你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。" } } // 标记该成员已查阅所有 workspace 文件,更新状态 wsFiles2 := r.listWorkspaceFiles() for i, f := range wsFiles2 { r.emit(Event{Type: EvtFileRead, Agent: name, Filename: f}) if i == 0 { r.setStatus(StatusWorking, name, fmt.Sprintf("正在阅读 %s ...", f)) } } memberMsgs := []llm.Message{ llm.NewMsg("system", memberSystem), llm.NewMsg("user", taskMsg), } var finalReply string for round := 0; round < 10; round++ { result, err := member.ChatWithTools(ctx, memberMsgs, tools, nil) if err != nil { mu.Lock() results[name] = fmt.Sprintf("[error] %v", err) mu.Unlock() return } r.emitUsage(name, result.Usage) if len(result.ToolCalls) == 0 { finalReply = result.Content break } assistantMsg := llm.Message{Role: "assistant", Content: result.Content, ToolCalls: result.ToolCalls} memberMsgs = append(memberMsgs, assistantMsg) r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: "", Streaming: true}) for _, tc := range result.ToolCalls { toolResult := r.executeToolCall(tc) memberMsgs = append(memberMsgs, llm.NewToolResultMsg(tc.ID, toolResult)) } r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: "thinking", Streaming: true}) } r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: "", Streaming: false}) if finalReply == "" { memberMsgs = append(memberMsgs, llm.NewMsg("user", "请根据以上所有工具调用结果,直接输出完整的任务回复。不要再调用任何工具。")) result, err := member.ChatWithTools(ctx, memberMsgs, nil, nil) if err == nil && result.Content != "" { finalReply = result.Content r.emitUsage(name, result.Usage) } } if finalReply == "" { finalReply = "[任务执行完成,但未产生文本回复]" } // 文件保存路由:模板路由成功时,results/board 只存简短摘要 statusMsg, routed := r.saveAgentOutput(name, finalReply, t) if routed { mu.Lock() results[name] = statusMsg mu.Unlock() r.AppendHistory("member", name, finalReply) if strings.TrimSpace(statusMsg) != "" { board.Add(name, statusMsg, "draft") } } else { mu.Lock() results[name] = finalReply mu.Unlock() r.AppendHistory("member", name, finalReply) if strings.TrimSpace(finalReply) != "" { board.Add(name, finalReply, "draft") } } go r.updateMemberMemory(context.Background(), member, t, finalReply) }(memberName, task) } wg2.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() // 没有看板内容且没有 workspace 文件,跳过 challenge if boardCtx == "" && len(r.listWorkspaceFiles()) == 0 { 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 // 注入 workspace 文件内容,让 challenger 能看到实际文档 if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { extraCtx = wsCtx + "\n\n" + extraCtx } // 注入团队成员列表 teamCtx := r.buildTeamXML() if teamCtx != "" { extraCtx = teamCtx + "\n\n" + extraCtx } if r.systemRules != "" { extraCtx = r.systemRules + "\n\n" + extraCtx } // 注入项目模板上下文 if projectCtx := r.buildProjectContext(n); projectCtx != "" { extraCtx = extraCtx + "\n\n" + projectCtx } memberSystem := member.BuildSystemPrompt(extraCtx) memberMsgs := []llm.Message{ llm.NewMsg("system", memberSystem+"\n\n审阅 workspace 中的文档内容(而非看板摘要)。如果发现问题或需要质疑,请输出 CHALLENGE:你的具体意见。如果没有问题,输出 AGREE。注意:只评审你职责范围内的内容。禁止使用@提及任何人,禁止建议分配任务。"), llm.NewMsg("user", "请审阅 workspace 中的文档并给出你的专业反馈。"), } var reply strings.Builder _, usage, err := member.ChatWithUsage(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.emitUsage(n, usage) r.emit(Event{Type: EvtAgentMessage, Agent: n, Role: "challenge", Content: "", Streaming: false}) result := reply.String() // 持久化完整的 challenge 回复 if r.Store != nil && result != "" { gid := &r.currentGroupID r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: n, Role: "challenge", Content: result, GroupID: gid, }) } 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) // 持久化 user 消息,记录 group_id 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() }() // 检测用户是否直接 @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.pendingPlanReply = "" 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 _, usage, err := member.ChatWithUsage(ctx, r.memberConvos[memberName], func(token string) { memberReply.WriteString(token) }) if err != nil { r.setStatus(StatusPending, "", "") return err } r.emitUsage(memberName, usage) result := memberReply.String() r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("assistant", result)) r.AppendHistory("member", memberName, result) // 追加成员回复到任务计划文档 r.appendPlanLog(memberName, userName, result) // 智能判断输出类型(含项目模板路由) _, routed := r.saveAgentOutput(memberName, result, "") if routed { r.lastActiveMember = "" // 文档产出,对话结束 } else if !isDocument(result) { // lastActiveMember 保持不变,用户可以继续回复 } else { r.lastActiveMember = "" // 文档产出,对话结束 } r.setStatus(StatusPending, "", "") return nil } r.setStatus(StatusThinking, "", "") // 构建 system prompt teamXML := r.buildTeamXML() skillXML := skill.ToXML(r.skillMeta) // 成员执行任务时使用 // master 只需要知道 skill 名称和描述,不需要具体调用方式 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 } // 注入项目模板上下文到 master if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" { extraContext = extraContext + "\n\n" + projectCtx } systemPrompt := r.master.BuildSystemPrompt(extraContext) sysMsg := llm.NewMsg("system", systemPrompt+fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode)) // 使用持久化的会话历史 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 < 12; iteration++ { log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration) // Before the master ChatWithUsage call if iteration > 0 { r.setStatus(StatusWorking, r.master.Config.Name, "正在编写文档...") } var masterReply strings.Builder docStreamCut := false // 文件驱动模式:检测到文档后停止向前端 streaming _, usage, err := r.master.ChatWithUsage(ctx, masterMsgs, func(token string) { masterReply.WriteString(token) // 文件驱动模式:检测到 # 标题 开头的文档后,停止向前端发送 if r.projectTemplate != nil && !docStreamCut { text := masterReply.String() trimmed := strings.TrimSpace(text) if strings.HasPrefix(trimmed, "# ") || strings.Contains(text, "\n# ") { docStreamCut = true // 提取文档标题,更新工作状态 title := extractTitle(text) if title != "" { r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在编写《%s》...", title)) } else { r.setStatus(StatusWorking, r.master.Config.Name, "正在编写文档...") } return } } if !docStreamCut { 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 } 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) // 先检查增量编辑指令 var savedDocTitles []string var savedDocFilenames []string persistContent := reply if editFile, edited := r.applyDocumentEdit(reply); edited { persistContent = fmt.Sprintf("已更新《%s》", strings.TrimSuffix(editFile, ".md")) savedDocTitles = append(savedDocTitles, strings.TrimSuffix(editFile, ".md")) savedDocFilenames = append(savedDocFilenames, editFile) } else if docs := splitDocuments(reply); len(docs) > 0 { for _, doc := range docs { title := extractTitle(doc) var filename string if r.projectTemplate != nil { if tf := r.matchTemplateFile(title); tf != nil { filename = tf.Path } } if filename == "" { 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) savedDocFilenames = append(savedDocFilenames, filename) } // 计算持久化内容(去掉文档部分) if r.projectTemplate != nil { stripped := r.stripDocuments(reply) if stripped == "" { var summary strings.Builder for _, t := range savedDocTitles { summary.WriteString(fmt.Sprintf("已完成《%s》\n", t)) } stripped = strings.TrimSpace(summary.String()) } persistContent = stripped } } // 发送 streaming 结束信号 if docStreamCut { // 文件驱动模式:streaming 被截断,用 replace 替换前端已累积的内容 r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: persistContent, Streaming: false, Action: "replace"}) } else if len(savedDocTitles) > 0 { // 有文档但未被截断(非文件驱动模式),replace 替换 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}) } // Part 模型存储:文档和交流分开存 if r.Store != nil { gid := &r.currentGroupID // 文档部分 → document part for i, title := range savedDocTitles { filename := "" if i < len(savedDocFilenames) { filename = savedDocFilenames[i] } r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", Content: title, Filename: filename, PartType: "document", GroupID: gid, }) } // 交流部分 → text part if persistContent != "" { r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", Content: persistContent, PartType: "text", GroupID: gid, }) } } 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 r.Mode == "build" && r.projectTemplate != nil { // 检查是否还有待产出的非动态文件 hasPending := false for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { hasPending = true break } } // 判断是否需要自动 continue: // - 有待产出文件 → 一定 continue(工作流没做完) // - 本轮有文档产出 → continue(刚产出文档,推进下一步) // - 都没有 → break(master 自己选择了停下来等用户,尊重它的决定) if hasPending || len(savedDocTitles) > 0 { stepCtx := r.buildWorkflowStep() log.Printf("[room %s] 工作流未完成(pending=%v, docs=%d),提示 master 继续", r.Config.Name, hasPending, len(savedDocTitles)) var continueMsg string if stepCtx != "" { continueMsg = stepCtx + "\n\n请继续推进。用 @成员名 分配任务,或自己产出文档(以 # 标题 开头)。不要重复已保存的文档内容。" } else { continueMsg = "文档已保存。请根据工作流程决定下一步行动。不要重复已保存的文档内容。" } continueLLMMsg := llm.NewMsg("user", continueMsg) masterMsgs = append(masterMsgs, continueLLMMsg) r.historyMu.Lock() r.masterHistory = append(r.masterHistory, continueLLMMsg) r.historyMu.Unlock() continue } // 所有固定文件已完成,本轮也没新文档 → master 自己选择了停下(如等用户确认) log.Printf("[room %s] 固定文件已完成,master 无新产出无分配,break 等用户", r.Config.Name) } else if len(savedDocTitles) > 0 && r.Mode == "build" { // 无 projectTemplate 但有文档产出,原逻辑 log.Printf("[room %s] master 产出了文档但未分配任务,提示继续", r.Config.Name) var fileList strings.Builder for _, t := range savedDocTitles { fileList.WriteString(fmt.Sprintf("- %s\n", t)) } wsFiles := r.listWorkspaceFiles() for _, f := range wsFiles { fileList.WriteString(fmt.Sprintf("- %s\n", f)) } continueMsg := fmt.Sprintf("文档已保存到 workspace:\n%s\n请根据工作流程,用 @成员名 分配下一步任务。不要重复输出文档内容。", fileList.String()) continueLLMMsg := llm.NewMsg("user", continueMsg) masterMsgs = append(masterMsgs, continueLLMMsg) r.historyMu.Lock() r.masterHistory = append(r.masterHistory, continueLLMMsg) r.historyMu.Unlock() continue } // 没有分配给任何成员的任务,master 直接回复了用户 break } // Plan 模式下不执行任务,提示用户切换到 Build 模式 if r.Mode != "build" { log.Printf("[room %s] plan 模式,拦截 %d 个任务分配", r.Config.Name, len(assignments)) r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "当前是 Plan 模式,无法执行任务。请切换到 Build 模式后发送消息开始执行。"}) break } // 并行执行成员任务 board := &SharedBoard{} results := r.runMembersParallel(ctx, assignments, board, skillXML) // 质疑轮 r.runChallengeRound(ctx, board, skillXML) // 将结果反馈给 master 审查 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)) } boardCtx := board.ToContext() feedbackMsg := "Team results:\n" + resultsStr.String() if boardCtx != "" { feedbackMsg += "\n\nTeam board:\n" + boardCtx } // 文件驱动模式:注入 workspace 文件内容供 master 审阅 if r.projectTemplate != nil { if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { feedbackMsg += "\n\n" + wsCtx } } else { // 附加当前产出物文件列表 if wsFiles := r.listWorkspaceFiles(); len(wsFiles) > 0 { feedbackMsg += "\n\n当前产出物文件:\n" for _, f := range wsFiles { feedbackMsg += "- 📎 " + f + "\n" } } } // 注入工作流步骤跟踪 if r.projectTemplate != nil { if stepCtx := r.buildWorkflowStep(); stepCtx != "" { feedbackMsg = stepCtx + "\n\n" + feedbackMsg } } feedbackMsg += "\n\n请审查以上成员结果,然后**立即行动**(二选一):\n1. 用 @成员名 分配下一步任务\n2. 自己直接输出文档(以 # 标题 开头,系统自动保存)\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) { // 过滤无意义任务:内容太短(如"你好"、"hi") if len([]rune(strings.TrimSpace(task))) < 10 { log.Printf("[memory] 跳过短任务记忆: %q", task) return } summaryPrompt := fmt.Sprintf(`基于这个任务: %q 总结核心经验(最多3条 bullet points)。 如果这个任务没有值得记忆的经验(如简单问候、闲聊),只输出 SKIP。`, task) memMsgs := append(msgs, llm.NewMsg("user", summaryPrompt)) summary, err := r.master.Chat(ctx, memMsgs, nil) if err != nil || summary == "" { return } // LLM 判断无值得记忆的内容 if strings.TrimSpace(summary) == "SKIP" { log.Printf("[memory] LLM 判断跳过记忆: %q", task) return } filename := time.Now().Format("2006-01") + ".md" existing, _ := os.ReadFile(filepath.Join(r.master.Dir, "memory", filename)) taskTitle := task if len([]rune(taskTitle)) > 50 { taskTitle = string([]rune(taskTitle)[:50]) } content := string(existing) + fmt.Sprintf("\n## %s — %s\n\n%s\n", time.Now().Format("2006-01-02"), taskTitle, summary) r.master.SaveMemory(filename, content) // 自动压缩:当月文件 > 20KB 时触发 memFile := filepath.Join(r.master.Dir, "memory", filename) if info, err := os.Stat(memFile); err == nil && info.Size() > 20*1024 { log.Printf("[memory] 当月文件 %s 超过 20KB,触发压缩", filename) go r.master.CompressMemory(context.Background()) } } // CompressAllMemory 压缩 room 中所有 agent(master + members)的记忆。 func (r *Room) CompressAllMemory(ctx context.Context) { if r.master != nil { if err := r.master.CompressMemory(ctx); err != nil { log.Printf("[memory] master 压缩失败: %v", err) } } for name, member := range r.members { if err := member.CompressMemory(ctx); err != nil { log.Printf("[memory] 成员 %s 压缩失败: %v", name, err) } } log.Printf("[memory] 全部 agent 记忆压缩完成") } // updateMemberMemory 为成员 agent 保存任务经验。 func (r *Room) updateMemberMemory(ctx context.Context, member *agent.Agent, task, result string) { // 过滤无意义任务 if len([]rune(strings.TrimSpace(task))) < 10 { return } // 限制 result 输入长度,节省 token resultRunes := []rune(result) if len(resultRunes) > 500 { result = string(resultRunes[:500]) } summaryPrompt := fmt.Sprintf(`基于这个任务和结果,总结核心经验(最多3条 bullet points)。 如果没有值得记忆的经验,只输出 SKIP。 任务: %s 结果: %s`, task, result) msgs := []llm.Message{ llm.NewMsg("system", "你是经验总结助手。提取任务中最有价值的经验教训。"), llm.NewMsg("user", summaryPrompt), } summary, err := member.Chat(ctx, msgs, nil) if err != nil || summary == "" || strings.TrimSpace(summary) == "SKIP" { return } filename := time.Now().Format("2006-01") + ".md" existing, _ := os.ReadFile(filepath.Join(member.Dir, "memory", filename)) taskTitle := task if len([]rune(taskTitle)) > 50 { taskTitle = string([]rune(taskTitle)[:50]) } content := string(existing) + fmt.Sprintf("\n## %s — %s\n\n%s\n", time.Now().Format("2006-01-02"), taskTitle, summary) member.SaveMemory(filename, content) log.Printf("[memory] 成员 %s 记忆已更新", member.Config.Name) } // stripDocuments 从文本中去除文档段落,只保留非文档部分(状态/指令文本)。 func (r *Room) stripDocuments(text string) string { docs := splitDocuments(text) if len(docs) == 0 { return text } result := text for _, doc := range docs { result = strings.Replace(result, doc, "", 1) } // 清理多余空行 for strings.Contains(result, "\n\n\n") { result = strings.ReplaceAll(result, "\n\n\n", "\n\n") } return strings.TrimSpace(result) } // splitDocuments 从文本中拆分出独立文档段落。 // 以 \n# 分割,只保留长度超过 200 字符的段落作为文档。 func splitDocuments(text string) []string { parts := strings.Split("\n"+text, "\n# ") if len(parts) <= 1 { return nil } var docs []string for _, part := range parts[1:] { doc := "# " + part // 去掉尾部可能的分隔符 doc = strings.TrimSpace(doc) if len([]rune(doc)) > 200 { docs = append(docs, doc) } } return docs } // isDocument 判断内容是否为文档产出物(而非对话/提问)。 // 文档特征:包含 markdown 标题且内容较长。 func isDocument(content string) bool { runeLen := len([]rune(content)) // 有 # 标题 且 超过 300 字 hasH1 := strings.Contains(content, "\n# ") || strings.HasPrefix(content, "# ") if hasH1 && runeLen > 300 { return true } // 有多个 ## 二级标题,说明是结构化文档 h2Count := strings.Count(content, "\n## ") if h2Count >= 2 && runeLen > 200 { return true } // 有 《》 书名号标题 且 内容较长 if strings.Contains(content, "《") && strings.Contains(content, "》") && runeLen > 300 { // 检查是否为结构化内容(多段落或有列表) if strings.Count(content, "\n") > 5 { return true } } return false } func extractTitle(content string) string { for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "# ") { return strings.TrimPrefix(line, "# ") } } // 尝试从 《》 书名号提取标题 if idx := strings.Index(content, "《"); idx != -1 { if end := strings.Index(content[idx:], "》"); end != -1 { return content[idx+len("《") : idx+end] } } // 尝试 ## 二级标题 for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "## ") { return strings.TrimPrefix(line, "## ") } } return "" } // titleToFilename 从文档标题生成文件名,如 "主角小传:林远" → "主角小传-林远.md" func titleToFilename(title, agentName string) string { if title == "" { return fmt.Sprintf("%s-%s.md", agentName, time.Now().Format("20060102-150405")) } // 替换不适合文件名的字符 name := strings.NewReplacer( ":", "-", ":", "-", "/", "-", "\\", "-", " ", "-", " ", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "", ).Replace(title) // 去除连续的短横线 for strings.Contains(name, "--") { name = strings.ReplaceAll(name, "--", "-") } name = strings.Trim(name, "-") if name == "" { return fmt.Sprintf("%s-%s.md", agentName, time.Now().Format("20060102-150405")) } return name + ".md" } 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) // 检测行内 @成员名(支持行首和行内位置,如 "要求 @搜索员 补充信息") atIdx := strings.Index(trimmed, "@") if atIdx >= 0 { rest := trimmed[atIdx+1:] if idx := strings.IndexAny(rest, " \t"); idx > 0 { // @name 任务内容 name := strings.TrimSpace(rest[:idx]) task := strings.TrimSpace(rest[idx+1:]) flush() currentName = name if task != "" { currentTask.WriteString(task) } continue } // @name 单独出现,任务内容在后续行 name := strings.TrimSpace(rest) if name != "" { flush() currentName = name 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 } // executeToolCall 执行 tool call,返回执行结果 func (r *Room) executeToolCall(tc llm.ToolCall) string { var args struct { Command string `json:"command"` } if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return fmt.Sprintf("参数解析错误: %v", err) } // 设置 SKILLS_ROOT 环境变量(指向 skills/ 根目录) skillPath := skill.SkillPathByToolName(r.skillMeta, tc.Function.Name) skillsRoot := filepath.Dir(skillPath) // skill 的父目录即 skills/ if skillPath == "" { skillsRoot = "skills" } // 转为绝对路径 if abs, err := filepath.Abs(skillsRoot); err == nil { skillsRoot = abs } log.Printf("[tool] 执行: %s, 命令: %s", tc.Function.Name, args.Command) cmd := exec.Command("bash", "-c", args.Command) cmd.Env = append(os.Environ(), "SKILLS_ROOT="+skillsRoot) cmd.Dir = r.Dir output, err := cmd.CombinedOutput() result := string(output) if err != nil { result = fmt.Sprintf("命令执行错误: %v\n输出:\n%s", err, result) } // 限制输出长度,避免 token 爆炸 if len(result) > 10000 { result = result[:10000] + "\n... (输出已截断)" } log.Printf("[tool] 结果 (%d 字符): %.200s", len(result), result) return result } // buildSkillSummary 为 master 构建简要的 skill 清单(只有名称和描述,不含调用方式) func (r *Room) buildSkillSummary() string { if len(r.skillMeta) == 0 { return "" } var sb strings.Builder sb.WriteString("\n") sb.WriteString("以下工具可供团队成员使用,你可以在分配任务时提示成员使用对应的工具:\n") for _, m := range r.skillMeta { fmt.Fprintf(&sb, " - %s: %s\n", m.Name, m.Description) } sb.WriteString("") return sb.String() } // buildWorkflowStep 根据 workspace 中已存在的文件,构建 phase-aware 的工作流进度上下文 func (r *Room) buildWorkflowStep() string { if r.projectTemplate == nil { return "" } var completed []string var pending []string minPendingPhase := 999 for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); err == nil { completed = append(completed, f.Path) } else { pending = append(pending, fmt.Sprintf("%s (@%s, phase:%d)", f.Path, f.Owner, f.Phase)) if f.Phase < minPendingPhase { minPendingPhase = f.Phase } } } var sb strings.Builder sb.WriteString("\n") sb.WriteString("已完成的文件:\n") for _, f := range completed { sb.WriteString(fmt.Sprintf(" [done] %s\n", f)) } sb.WriteString("待产出的文件:\n") for _, f := range pending { sb.WriteString(fmt.Sprintf(" [todo] %s\n", f)) } sb.WriteString("\n\n") // 找到当前最小 phase 的待办任务,给出具体指令 if minPendingPhase < 999 { sb.WriteString(fmt.Sprintf("\n当前阶段:phase %d\n", minPendingPhase)) for _, f := range r.projectTemplate.Files { if f.IsDir || f.Dynamic { continue } if f.Phase != minPendingPhase { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) if _, err := os.Stat(fpath); err == nil { continue // 已完成 } if f.Owner == r.master.Config.Name { sb.WriteString(fmt.Sprintf("- 你需要产出:%s(直接输出文档正文,以 # 标题 开头)\n", f.Path)) } else { sb.WriteString(fmt.Sprintf("- 分配给 @%s:%s\n", f.Owner, f.Path)) } } sb.WriteString("\n") sb.WriteString("请按上述指令推进。文档型输出只输出文档正文,系统自动保存。不要重复已完成的文档内容。") } else { // 所有固定文件已完成 sb.WriteString("所有模板文件已完成。请根据你的 AGENT.md 工作流程决定下一步行动(如向用户交付、或进入下一阶段)。") } return sb.String() } // matchTemplateFile 按标题匹配模板文件。 // "创作需求书" -> "创作需求书.md" // "主角小传:林远" -> "主角小传.md"(前缀匹配) func (r *Room) matchTemplateFile(title string) *ProjectFile { if r.projectTemplate == nil || title == "" { return nil } // 精确匹配(去掉 .md 后缀比较) for i := range r.projectTemplate.Files { f := &r.projectTemplate.Files[i] if f.IsDir || f.Dynamic { continue } fname := strings.TrimSuffix(f.Path, ".md") if fname == title { return f } } // 前缀匹配(如 "主角小传:林远" 匹配 "主角小传.md") for i := range r.projectTemplate.Files { f := &r.projectTemplate.Files[i] if f.IsDir || f.Dynamic { continue } fname := strings.TrimSuffix(f.Path, ".md") if strings.HasPrefix(title, fname) { return f } } // 关键词匹配:标题中包含文件名的关键部分,或文件名包含标题 for i := range r.projectTemplate.Files { f := &r.projectTemplate.Files[i] if f.IsDir || f.Dynamic { continue } base := strings.TrimSuffix(f.Path, ".md") if strings.Contains(base, title) || strings.Contains(title, base) { return f } // 关键词拆分匹配 keywords := strings.FieldsFunc(base, func(r rune) bool { return r == '与' || r == '和' || r == '·' }) for _, kw := range keywords { if len([]rune(kw)) >= 2 && strings.Contains(title, kw) { return f } } } return nil } // findOwnerFiles 查找某 agent 负责的所有文件 func (r *Room) findOwnerFiles(agentName string) []ProjectFile { if r.projectTemplate == nil { return nil } var files []ProjectFile for _, f := range r.projectTemplate.Files { if f.Owner == agentName && !f.IsDir && !f.Dynamic { files = append(files, f) } } return files } // splitContentAndStatus 将 agent 输出分为文件内容和状态消息。 // 整个输出作为文件内容,自动生成状态摘要。 func splitContentAndStatus(reply, filename string) (fileContent, statusMsg string) { fileContent = reply name := strings.TrimSuffix(filename, ".md") statusMsg = fmt.Sprintf("已完成《%s》", name) return } // buildProjectContext 构建项目模板上下文,注入到成员 system prompt func (r *Room) buildProjectContext(agentName string) string { if r.projectTemplate == nil { return "" } var sb strings.Builder sb.WriteString("\n") sb.WriteString("项目文件结构(系统自动管理文件保存):\n\n") for _, f := range r.projectTemplate.Files { if f.IsDir { sb.WriteString(fmt.Sprintf(" %s (目录)\n", f.Path)) continue } if f.Dynamic { sb.WriteString(fmt.Sprintf(" ... @%s phase:%d (动态扩展)\n", f.Owner, f.Phase)) continue } marker := "" if f.Owner == agentName { marker = " ← 你负责" } sb.WriteString(fmt.Sprintf(" %s @%s phase:%d%s\n", f.Path, f.Owner, f.Phase, marker)) } sb.WriteString("\n输出规范:直接输出文件的 Markdown 正文内容(以 # 标题 开头),系统会自动保存到对应文件。\n") sb.WriteString("不要在文档中夹杂状态描述或对话内容。\n") sb.WriteString("") return sb.String() } // saveAgentOutput 统一处理成员产出的文件保存路由。 // 优先走项目模板路由,降级走原有 isDocument 逻辑。 // 返回 (statusMsg, routed):routed=true 表示走了模板路由,statusMsg 是短摘要。 func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) { if r.projectTemplate != nil { // 按 owner 查找该成员负责的文件 ownerFiles := r.findOwnerFiles(name) if len(ownerFiles) >= 1 { var targetFile *ProjectFile if len(ownerFiles) == 1 { // 只负责一个文件,直接路由 targetFile = &ownerFiles[0] } else { // 负责多个文件,多级匹配策略 title := extractTitle(finalReply) // 策略1:标题精确匹配模板文件 if title != "" { if tf := r.matchTemplateFile(title); tf != nil && tf.Owner == name { targetFile = tf } } // 策略2:用回复开头文本模糊匹配文件名(LLM 可能不写 # 前缀) if targetFile == nil { firstLine := strings.TrimSpace(strings.SplitN(finalReply, "\n", 2)[0]) firstLine = strings.TrimLeft(firstLine, "# ") if firstLine != "" { for i := range ownerFiles { base := strings.TrimSuffix(ownerFiles[i].Path, ".md") if strings.Contains(firstLine, base) || strings.Contains(base, firstLine) { targetFile = &ownerFiles[i] break } } } } // 策略3:匹配尚未产出的文件(优先 phase 小的) if targetFile == nil { for i := range ownerFiles { fpath := filepath.Join(r.Dir, "workspace", ownerFiles[i].Path) if _, err := os.Stat(fpath); os.IsNotExist(err) { targetFile = &ownerFiles[i] break } } } // 策略4:内容够长(>200字),强制路由到最近的模板文件(覆盖更新) if targetFile == nil && len([]rune(finalReply)) > 200 { // 取最后一个文件(phase 大的)作为更新目标 targetFile = &ownerFiles[len(ownerFiles)-1] } } if targetFile != nil { fileContent, _ := splitContentAndStatus(finalReply, targetFile.Path) // 自动补标题:如果内容没有 # 标题行,从文件名生成一个 if !strings.HasPrefix(strings.TrimSpace(fileContent), "# ") { docTitle := strings.TrimSuffix(targetFile.Path, ".md") fileContent = "# " + docTitle + "\n\n" + fileContent } r.saveWorkspace(targetFile.Path, fileContent) // 不再 emit EvtAgentMessage,交给调用方通过 generateBriefMessage 生成 if r.memberArtifacts == nil { r.memberArtifacts = make(map[string]string) } r.memberArtifacts[name] = targetFile.Path r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task}) return targetFile.Path, true } } } // 降级:原有 isDocument 逻辑,也走 Part 模型 if isDocument(finalReply) { title := extractTitle(finalReply) if r.memberArtifacts == nil { r.memberArtifacts = make(map[string]string) } var filename string if existing, ok := r.memberArtifacts[name]; ok { filename = existing } else { filename = titleToFilename(title, name) r.memberArtifacts[name] = filename } r.saveWorkspace(filename, finalReply) r.emit(Event{Type: EvtArtifact, Agent: name, Filename: filename, Title: title}) r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task}) // 返回 routed=true,让调用方通过 generateBriefMessage 生成简短消息 return filename, true } // 纯交流型输出 r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: finalReply}) r.lastActiveMember = name r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task}) return "", false } // generateBriefMessage 成员完成文档后,追加一次短 LLM 调用生成包含关键结论的交流消息 func (r *Room) generateBriefMessage(ctx context.Context, ag *agent.Agent, name, docContent, task string) string { title := extractTitle(docContent) prompt := fmt.Sprintf( "你刚完成了文档《%s》(任务:%s)。\n"+ "请用1句话简短告知团队完成情况,包含关键结论。末尾写 @master 请查阅。\n"+ "只输出这一句话,不要其他内容。", title, task) msgs := []llm.Message{ llm.NewMsg("system", "你是"+name+",用一句话简短汇报工作结果。"), llm.NewMsg("user", prompt), } reply, _, err := ag.ChatWithUsage(ctx, msgs, nil) if err != nil { return fmt.Sprintf("《%s》已完成,@master 请查阅。", title) } return strings.TrimSpace(reply) } // applyDocumentEdit 解析并应用文档编辑指令 // 格式:<<>> // // <<>> // 要替换的旧文本 // <<>> // 新文本 // <<>> func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) { editRe := regexp.MustCompile(`(?s)<<>>\s*<<>>\s*(.+?)\s*<<>>\s*(.+?)\s*<<>>`) matches := editRe.FindAllStringSubmatch(content, -1) if len(matches) == 0 { return "", false } for _, m := range matches { fname := strings.TrimSpace(m[1]) oldText := strings.TrimSpace(m[2]) newText := strings.TrimSpace(m[3]) fpath := filepath.Join(r.Dir, "workspace", fname) data, err := os.ReadFile(fpath) if err != nil { continue } original := string(data) if !strings.Contains(original, oldText) { continue } updated := strings.Replace(original, oldText, newText, 1) r.saveWorkspace(fname, updated) // saveWorkspace 内部会自动保存旧版本 filename = fname applied = true } return } func (r *Room) buildTeamXML() string { var sb strings.Builder sb.WriteString("\n") for name, a := range r.members { fmt.Fprintf(&sb, " \n %s\n %s\n \n", name, a.Config.Description) } sb.WriteString("") return sb.String() } // appendPlanLog 将沟通记录追加到任务计划文档 func (r *Room) appendPlanLog(from, to, content string) { if r.planFilename == "" { return } path := filepath.Join(r.Dir, "workspace", r.planFilename) f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return } defer f.Close() entry := fmt.Sprintf("\n---\n**[%s] %s → %s**\n\n%s\n", time.Now().Format("15:04:05"), from, to, content) f.WriteString(entry) } func (r *Room) saveWorkspace(filename, content string) { dir := filepath.Join(r.Dir, "workspace") os.MkdirAll(dir, 0755) fpath := filepath.Join(dir, filename) // 版本追踪:保存旧版本到数据库 if r.Store != nil { if old, err := os.ReadFile(fpath); err == nil && len(old) > 0 { r.Store.InsertFileVersion(r.Config.Name, filename, string(old), "") } } os.WriteFile(fpath, []byte(content), 0644) // 通知前端 workspace 文件更新 r.emit(Event{Type: EvtWorkspaceFile, Filename: filename, Action: "updated"}) } func (r *Room) listWorkspaceFiles() []string { dir := filepath.Join(r.Dir, "workspace") entries, err := os.ReadDir(dir) if err != nil { return nil } var files []string for _, e := range entries { if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") { files = append(files, e.Name()) } } return files } // buildWorkspaceContext 读取 workspace 目录下所有文件内容,作为成员上下文。 func (r *Room) buildWorkspaceContext() string { wsDir := filepath.Join(r.Dir, "workspace") entries, err := os.ReadDir(wsDir) if err != nil { return "" } var sb strings.Builder for _, e := range entries { if e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } content, err := os.ReadFile(filepath.Join(wsDir, e.Name())) if err != nil || len(content) == 0 { continue } // 限制单个文件最大 8000 字符 text := string(content) if len([]rune(text)) > 8000 { text = string([]rune(text)[:8000]) + "\n...(截断)" } sb.WriteString(fmt.Sprintf("\n--- 📎 %s ---\n%s\n", e.Name(), text)) } if sb.Len() == 0 { return "" } return "\n以下是团队已产出的文档,可供参考和评审:" + sb.String() + "" } 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) }