scorpio d6df056687 feat: Part 模型 + 文件版本追踪 + 写手团队工作流 v2
- 数据层:messages 表增加 part_type 字段,新建 file_versions 表支持版本追踪
- 后端:saveWorkspace 版本追踪、saveAgentOutput 源头分离、generateBriefMessage 成员简报
- 后端:applyDocumentEdit 增量编辑、buildWorkflowStep phase-aware 工作流引擎
- API:文件版本查询/回退接口
- 前端:part_type 驱动渲染,产物面板版本历史
- 新增写手团队(主编/搜索员/策划编辑/合规审查员)配置
- store 模块、scheduler 模块、web-search skill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:44:34 +08:00

2017 lines
65 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"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("<team_board>\n")
for _, e := range b.entries {
fmt.Fprintf(&sb, " <entry type=\"%s\" author=\"%s\">\n%s\n </entry>\n", e.Type, e.Author, e.Content)
}
sb.WriteString("</team_board>")
return sb.String()
}
func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
data, err := os.ReadFile(filepath.Join(roomDir, "room.md"))
if err != nil {
return nil, err
}
cfg, err := parseRoomConfig(data)
if err != nil {
return nil, err
}
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan", 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<important>你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。</important>"
}
}
// 标记该成员已查阅所有 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<important>你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。</important>"
}
}
// 标记该成员已查阅所有 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刚产出文档推进下一步
// - 都没有 → breakmaster 自己选择了停下来等用户,尊重它的决定)
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 中所有 agentmaster + 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("<available_skills>\n")
sb.WriteString("以下工具可供团队成员使用,你可以在分配任务时提示成员使用对应的工具:\n")
for _, m := range r.skillMeta {
fmt.Fprintf(&sb, " - %s: %s\n", m.Name, m.Description)
}
sb.WriteString("</available_skills>")
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("<workflow_progress>\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("</workflow_progress>\n\n")
// 找到当前最小 phase 的待办任务,给出具体指令
if minPendingPhase < 999 {
sb.WriteString(fmt.Sprintf("<next_action>\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("</next_action>\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("<project_template>\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("</project_template>")
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 解析并应用文档编辑指令
// 格式:<<<EDIT 文件名.md>>>
//
// <<<FIND>>>
// 要替换的旧文本
// <<<REPLACE>>>
// 新文本
// <<<END>>>
func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) {
editRe := regexp.MustCompile(`(?s)<<<EDIT\s+(.+?)>>>\s*<<<FIND>>>\s*(.+?)\s*<<<REPLACE>>>\s*(.+?)\s*<<<END>>>`)
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("<team_members>\n")
for name, a := range r.members {
fmt.Fprintf(&sb, " <member>\n <name>%s</name>\n <description>%s</description>\n </member>\n", name, a.Config.Description)
}
sb.WriteString("</team_members>")
return sb.String()
}
// appendPlanLog 将沟通记录追加到任务计划文档
func (r *Room) appendPlanLog(from, to, content string) {
if r.planFilename == "" {
return
}
path := filepath.Join(r.Dir, "workspace", r.planFilename)
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
entry := fmt.Sprintf("\n---\n**[%s] %s → %s**\n\n%s\n",
time.Now().Format("15:04:05"), from, to, content)
f.WriteString(entry)
}
func (r *Room) saveWorkspace(filename, content string) {
dir := filepath.Join(r.Dir, "workspace")
os.MkdirAll(dir, 0755)
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 "<workspace_files>\n以下是团队已产出的文档可供参考和评审" + sb.String() + "</workspace_files>"
}
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)
}