- 数据层: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>
2017 lines
65 KiB
Go
2017 lines
65 KiB
Go
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(刚产出文档,推进下一步)
|
||
// - 都没有 → 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("<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)
|
||
}
|