Compare commits

...

10 Commits

Author SHA1 Message Date
scorpio
6804dd099a chore: 更新 progress.md 记录 Task 5 完成 2026-03-10 09:44:43 +08:00
scorpio
fd003638d6 test: 添加文件操作工具测试,修复 glob 递归匹配 2026-03-10 09:43:59 +08:00
scorpio
a41c4f3d08 feat: 注册 git_status, git_diff, git_commit 工具定义 2026-03-10 09:40:47 +08:00
scorpio
cbbb7d399d 集成 ToolExecutor 到 Room
- 在 Room 结构体中添加 ToolExecutor 字段
- 在 Load 函数中初始化 ToolExecutor
2026-03-10 09:37:42 +08:00
scorpio
9a4ff4713a fix: 添加 glob 函数 pattern 参数的安全验证 2026-03-10 09:34:57 +08:00
scorpio
ac21126d2b fix: 添加 glob/grep/gitDiff 函数的 safePath 路径验证 2026-03-10 09:32:39 +08:00
scorpio
5bc07f2ba5 fix: 修复路径遍历安全漏洞和editFile替换逻辑问题 2026-03-10 09:30:15 +08:00
scorpio
6fe1933161 feat: 实现工具执行器
- 添加 Executor 结构体,支持 workspace 目录操作
- 实现 9 个工具: glob, grep, read_file, edit_file, write_file,
  list_workspace, git_status, git_diff, git_commit
- JSON 解析参数,错误处理完善
2026-03-10 09:27:55 +08:00
scorpio
ae4afe9271 add file operation tool definitions 2026-03-10 09:23:59 +08:00
scorpio
4280054944 chore: add .worktrees to gitignore 2026-03-10 09:20:46 +08:00
29 changed files with 2015 additions and 243 deletions

View File

@ -45,7 +45,10 @@
"WebFetch(domain:medium.com)", "WebFetch(domain:medium.com)",
"Bash(for agent in 主编 策划编辑 搜索员 合规审查员)", "Bash(for agent in 主编 策划编辑 搜索员 合规审查员)",
"Bash(do echo \"========== $agent SOUL.md ==========\")", "Bash(do echo \"========== $agent SOUL.md ==========\")",
"Read(//Users/wt/Documents/work/tmp/agent-team/web/agents/writing-team/$agent/**)" "Read(//Users/wt/Documents/work/tmp/agent-team/web/agents/writing-team/$agent/**)",
"WebFetch(domain:opencode.ai)",
"WebFetch(domain:deepwiki.com)",
"WebFetch(domain:fabianhertwig.com)"
] ]
} }
} }

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ node_modules/
# SQLite WAL/SHM 临时文件data.db 本身需要同步) # SQLite WAL/SHM 临时文件data.db 本身需要同步)
data.db-wal data.db-wal
data.db-shm data.db-shm
.worktrees/

View File

@ -23,6 +23,14 @@ skills:
- **合同律师**:专注合同审查、起草和风险评估 - **合同律师**:专注合同审查、起草和风险评估
- **合规专员**:负责合规检查、风险识别和合规建议 - **合规专员**:负责合规检查、风险识别和合规建议
```project-template
workspace/
├── 法律需求分析书.md @法律总监 phase:1
├── 合同审查报告.md @合同律师 phase:1
├── 合规检查报告.md @合规专员 phase:1
└── 法律意见书.md @法律总监 phase:2
```
## 核心能力 ## 核心能力
- 合同审查与风险评估 - 合同审查与风险评估

View File

@ -38,6 +38,11 @@ skills:
- 补充遗漏的关键点 - 补充遗漏的关键点
- 提供结构化的最终建议 - 提供结构化的最终建议
### 灵活使用前置材料
- 不是每个案件都需要全部前置报告
- 如果某份报告与当前需求无关(如纯咨询不需要合同审查),指示对应成员产出简短的"不适用"声明即可
- 确保所有前置材料就位后,再产出最终的《法律意见书》
## 注意事项 ## 注意事项
1. 不提供具体的法律文书模板(需要时可指导合同律师起草) 1. 不提供具体的法律文书模板(需要时可指导合同律师起草)

View File

@ -28,14 +28,13 @@ skills:
```project-template ```project-template
workspace/ workspace/
├── 创作需求书.md @主编 phase:1 ├── 创作需求书.md @主编 phase:1
├── TodoList.md @主编 phase:1 ├── 市场调研报告.md @搜索员 phase:1
├── 市场调研报告.md @搜索员 phase:2 ├── 合规审查报告.md @合规审查员 phase:1
├── 合规审查报告.md @合规审查员 phase:2 ├── 故事方案评审.md @策划编辑 phase:1
├── 故事方案评审.md @策划编辑 phase:3 ├── 主角小传.md @主编 phase:1
├── 主角小传.md @主编 phase:4 ├── 世界观与角色设定.md @主编 phase:1
├── 世界观与角色设定.md @主编 phase:4 ├── 故事大纲.md @主编 phase:1
├── 故事大纲.md @主编 phase:4 ├── 文档评审报告.md @策划编辑 phase:1
├── 文档评审报告.md @策划编辑 phase:5
└── 章节/ └── 章节/
└── ... @写手 phase:6 └── ... @写手 phase:2
``` ```

View File

@ -1,22 +1,18 @@
## 2026-03-09 — 我有一个关于一名土木工程师 创越到五国十代的想法 ## 2026-03-09 — 我有一个穿越剧的想法,主角 林远, 一个很专业的现代 土木建筑工程师,突然穿越到五代十国的故事
* **明确反差定位**:现代专业人才(土木工程师)穿越到混乱时代(五代十国),核心看点在于“专业知识降维打击”与“乱世生存规则”的碰撞,需优先确定是侧重“基建救国”、“工程权谋”还是“文明冲突” - **明确历史背景**:五代十国是复杂的历史时期,需要确定具体的时间节点和政权归属,这直接影响故事的政治格局和冲突设定
* **深挖专业细节**:土木工程知识(筑城、修路、水利)是故事独特性的基石,也是爽点来源,但需平衡专业性与可读性,避免变成工程手册,需考虑知识呈现方式(如通过解决具体危机) - **专业技能落地**:土木工程师的技能需要具体化应用场景(如城防、水利、宫殿、道路),并考虑技术代差带来的冲突与机遇
* **构建合理冲突**:故事张力不仅来自技术应用,更源于现代工程思维(效率、标准化、以人为本)与古代社会政治、军事、伦理规则的冲突,这是推动剧情和角色成长的关键 - **故事核心定位**:需在基建强国、权谋争霸、技术革新、个人生存等方向中选择一个主基调,以聚焦叙事
## 2026-03-09 — 1、尊重历史受众是理工科人员希望看到 自己学知识 在古代如何创造奇迹的爽文 ## 2026-03-09 — 1:一部存满工程资料但电量有限的手机 2: 刘知远 3: 硬核技术 4: 技术爱好者,理工科人对自己
2、辅佐以工代赈
- **明确核心爽点**:用户的核心诉求是“硬核技术应用”的幻想满足感,而非传统权谋或宫斗。所有设定(金手指、时代、主角职业)都为此服务。
- **从具体要素推导方向**:用户提供了几个看似零散但关键的“坐标点”(手机、刘知远、硬核技术),需要主动将其串联,构建出合理的故事框架和待选方向供用户决策。
- **在用户无明确想法时提供结构化选择**:当用户表示“没更多想法”时,不应停滞,而是基于已有要素,提出几个在题材内具有典型性、差异化的故事路径(如强国/种田/求生)和风格选项,引导用户做出关键选择,将模糊想法具体化。
- **核心定位**:面向理工科读者的历史向专业爽文,核心看点是“现代工程知识在古代乱世(五代十国刘知远时期)的系统性应用与降维打击”,需平衡专业细节的硬核与情节的可读性。 ## 2026-03-09 — 强国路线、尊重历史、开篇是现代社会的地铁项目施工现场、主角是项目负责人,希望有一两场戏,体现用户的任
- **关键设定**:主角林远是携带专业资料库(手机)的土木工程师,行动模式是“辅佐势力+以工代赈”,通过实施具体工程项目(水利、城防、交通等)推动“技术救国”,故事需尊重基本历史框架。
- **叙事要点**:开篇需快速切入“生存危机+工程挑战”的具体场景(如灾后重建),用第一个成功项目(如改良水利、快速筑城)建立爽点与信任;后续冲突应围绕技术实现、资源博弈与政治周旋展开,避免沦为纯技术说明书。
## 2026-03-09 — 1A - **明确爽点定位**:用户的核心诉求是“硬核技术应用”的幻想满足感,而非传统权谋或宫斗。所有设定(金手指、时代、主角职业)都为此服务。
2: 城防 - **从具体要素推导方向**:用户提供了几个看似零散但关键的“坐标点”(手机、刘知远、硬核技术),需要主动将其串联,构建出合理的故事框架和待选方向供用户决策。
3技术冲突 - **在用户无明确想法时提供结构化选择**:当用户表示“没更多想法”时,不应停滞,而是基于已有要素,提出几个在题材内具有典型性、差异化的故事路径(如强国/种田/求生)和风格选项,引导用户做出关键选择,将模糊想法具体化。
* **核心爽点定位**:面向理工科读者的专业爽文,核心在于将现代土木工程知识(如材料科学、结构力学、施工组织)系统性地应用于古代城防场景,通过“技术降维打击”和“极限条件下的工程实现”制造阅读快感。
* **开篇矛盾设计**:将“生存危机”(流民身份)、“社会需求”(城防压力)与“技术解决方案”(以工代赈筑城)三者强绑定,能快速建立故事核心驱动力,并让后续的技术冲突、资源博弈和信任获取都围绕一个具体、紧迫的工程目标展开。
* **冲突层次规划**:首要冲突明确为“技术冲突”(现代工程思维 vs 古代经验法则),这决定了故事前期的叙事焦点和爽点来源,为后续引入更复杂的人际、政治冲突奠定了扎实的基础。

View File

@ -0,0 +1,6 @@
## 2026-03-09 — 请基于《创作需求书.md》和《市场调研报告.md》对项目进行合规审查。重点审查方向
1. **
1. **明确审查标准**:在合规审查中,需将抽象的审查方向(如“民族关系”)与具体文档中的实际表述(如“契丹威胁”)进行对照,并转化为可操作的修改建议(如使用中性称谓),使结论清晰、可执行。
2. **风险分级与聚焦**:对识别出的风险点进行等级评估(如“中”风险),有助于团队优先关注和处理最关键的问题,避免审查结论过于笼统或琐碎。

View File

@ -0,0 +1,15 @@
## 2026-03-09 — 请搜索以下资料:
1. **刘知远及后汉开国时期约947-948年的关键历史事件**:如刘知远
1. **明确需求与精准定位**:本次任务要求为“硬核技术穿越”提供历史锚点,因此搜索时需同时关注宏观历史事件和微观技术细节,如既梳理刘知远称帝等关键节点,也深挖城墙结构、材料应用等具体工程技术,确保资料兼具历史真实性与技术操作性。
2. **结构化整理与来源标注**:将庞杂信息按“历史事件-社会技术背景-可介入节点”分层归纳,并清晰标注来源(如维基百科、专业专栏),既提升了报告的可信度与条理性,也便于后续针对性查证和拓展,符合调研类文档的严谨要求。
## 2026-03-09 — 我想知道 其他类似的穿越剧网络小说的,评分和吐槽的
1. **明确区分任务目标与个人兴趣**:用户需求是分析“穿越剧网络小说的评分和吐槽”,但实际调研却偏向了具体历史时期(刘知远及后汉)的技术介入分析,这偏离了核心任务。经验是:执行任务时应严格对齐用户请求,避免将个人研究兴趣或假设性需求混入实际交付物中。
2. **结构化信息但缺乏针对性总结**:调研结果虽以报告形式呈现,内容有历史事件梳理,但未直接回应“类似穿越剧小说的评分、吐槽”这一需求。经验是:在总结或报告时,需确保每个部分都直接服务于原始问题,否则信息再完整也无效。
SKIP

View File

@ -0,0 +1,75 @@
# Agent Team 文件操作能力增强设计
## 目标
为 Agent Team 赋予类似 opencode/claudecode 的强大文件处理能力,支持智能搜索、大文件处理、精细编辑、多 agent 并行处理、Git 集成和文件可视化。
## 架构选择
- **工具调用方式**: Tool Calling - Agent 直接调用 LLM 时带上工具定义LLM 自主决定何时调用哪些工具
- **适用范围**: 全部 workspace 文件
## 工具定义
### 1. 智能文件搜索
| 工具 | 功能 | 参数 |
|------|------|------|
| `glob` | 模式匹配文件 | `pattern`: glob 模式 |
| `grep` | 内容搜索 | `pattern`: 正则, `path`: 目录, `include`: 文件过滤 |
### 2. 大文件处理
| 工具 | 功能 | 参数 |
|------|------|------|
| `read_file` | 读取文件 | `filename`, `offset`, `limit` |
| `get_line_count` | 查询总行数 | `filename` |
### 3. 精细编辑操作
| 工具 | 功能 | 参数 |
|------|------|------|
| `edit_file` | 编辑文件 | `filename`, `old_string`, `new_string` |
| `write_file` | 写入文件 | `filename`, `content` |
| `create_dir` | 创建目录 | `dir` |
| `delete_file` | 删除文件 | `filename` |
| `list_workspace` | 列出工作区文件 | - |
### 4. Git 集成
| 工具 | 功能 | 参数 |
|------|------|------|
| `git_status` | 查看变更状态 | - |
| `git_diff` | 查看变更 | `filename` |
| `git_commit` | 提交变更 | `message` |
## 实现策略
### Phase 1: 核心工具实现
- 实现 glob, grep, read_file, edit_file, write_file 工具
- 集成到 Room 的 Agent 调用中
### Phase 2: 大文件支持
- 添加 offset/limit 支持
- 实现 get_line_count
### Phase 3: Git 集成
- 实现 git_status, git_diff, git_commit
### Phase 4: 并行处理
- 添加文件锁机制
- 处理并发编辑冲突
## 数据流
```
User Message
Room.HandleUserMessage()
Agent.StreamWithTools([glob, grep, read_file, edit_file, ...])
LLM 自主选择工具 → 执行 → 返回结果
继续生成或结束
```

View File

@ -0,0 +1,754 @@
# Agent 文件操作能力增强实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为 Agent Team 实现类似 opencode 的文件操作工具glob, grep, read_file, edit_file, write_file, git 等),支持智能搜索、大文件处理、精细编辑
**Architecture:** 在 Room 层为 Agent 注册文件操作工具,通过 Tool Calling 让 LLM 自主调用工具执行文件操作
**Tech Stack:** Go + go-openai (Tool Calling)
---
## Task 1: 创建文件操作工具定义
**Files:**
- Create: `internal/room/tools/definitions.go`
**Step 1: 创建工具定义文件**
```go
package tools
import "github.com/sashabaranov/go-openai"
var FileTools = []openai.Tool{
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "glob",
Description: "模式匹配查找文件,支持 * ** ? 等通配符",
Parameters: `{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "glob 模式,如 **/*.go, *.ts"
}
},
"required": ["pattern"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "grep",
Description: "在文件中搜索内容,支持正则表达式",
Parameters: `{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "正则表达式搜索模式"
},
"path": {
"type": "string",
"description": "搜索目录路径"
},
"include": {
"type": "string",
"description": "文件过滤,如 *.go, *.ts"
}
},
"required": ["pattern"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "read_file",
Description: "读取文件内容,支持大文件分段读取",
Parameters: `{
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "文件名(相对于 workspace"
},
"offset": {
"type": "integer",
"description": "起始行号(从 0 开始)"
},
"limit": {
"type": "integer",
"description": "读取行数,默认 100"
}
},
"required": ["filename"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "edit_file",
Description: "编辑文件内容,使用字符串替换",
Parameters: `{
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "文件名(相对于 workspace"
},
"old_string": {
"type": "string",
"description": "要替换的原始内容"
},
"new_string": {
"type": "string",
"description": "替换后的新内容"
}
},
"required": ["filename", "old_string", "new_string"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "write_file",
Description: "写入或创建文件",
Parameters: `{
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "文件名(相对于 workspace"
},
"content": {
"type": "string",
"description": "文件内容"
}
},
"required": ["filename", "content"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "list_workspace",
Description: "列出工作区的所有文件",
Parameters: `{
"type": "object",
"properties": {}
}`,
},
},
}
```
**Step 2: 提交**
```bash
git add internal/room/tools/definitions.go
git commit -m "feat: 添加文件操作工具定义"
```
---
## Task 2: 实现工具执行器
**Files:**
- Create: `internal/room/tools/executor.go`
**Step 1: 实现工具执行器**
```go
package tools
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/sdaduanbilei/agent-team/internal/llm"
)
type Executor struct {
workspaceDir string
}
func NewExecutor(workspaceDir string) *Executor {
return &Executor{workspaceDir: workspaceDir}
}
func (e *Executor) Execute(toolCall llm.ToolCall) (string, error) {
switch toolCall.Function.Name {
case "glob":
return e.glob(toolCall.Function.Arguments)
case "grep":
return e.grep(toolCall.Function.Arguments)
case "read_file":
return e.readFile(toolCall.Function.Arguments)
case "edit_file":
return e.editFile(toolCall.Function.Arguments)
case "write_file":
return e.writeFile(toolCall.Function.Arguments)
case "list_workspace":
return e.listWorkspace()
case "git_status":
return e.gitStatus()
case "git_diff":
return e.gitDiff(toolCall.Function.Arguments)
case "git_commit":
return e.gitCommit(toolCall.Function.Arguments)
default:
return "", nil
}
}
type GlobArgs struct {
Pattern string `json:"pattern"`
}
func (e *Executor) glob(args string) (string, error) {
var a GlobArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
pattern := filepath.Join(e.workspaceDir, a.Pattern)
files, err := filepath.Glob(pattern)
if err != nil {
return "", err
}
var result []string
for _, f := range files {
rel, _ := filepath.Rel(e.workspaceDir, f)
result = append(result, rel)
}
if len(result) == 0 {
return "未找到匹配的文件", nil
}
return strings.Join(result, "\n"), nil
}
type GrepArgs struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
Include string `json:"include"`
}
func (e *Executor) grep(args string) (string, error) {
var a GrepArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
searchDir := e.workspaceDir
if a.Path != "" {
searchDir = filepath.Join(e.workspaceDir, a.Path)
}
re, err := regexp.Compile(a.Pattern)
if err != nil {
return "", err
}
var results []string
err = filepath.Walk(searchDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
if a.Include != "" {
matched, _ := filepath.Match(a.Include, info.Name())
if !matched {
return nil
}
}
content, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(content), "\n")
for i, line := range lines {
if re.MatchString(line) {
rel, _ := filepath.Rel(e.workspaceDir, path)
results = append(results, fmt.Sprintf("%s:%d: %s", rel, i+1, line))
}
}
return nil
})
if len(results) == 0 {
return "未找到匹配的内容", nil
}
// 限制结果数量
if len(results) > 100 {
results = results[:100]
results = append(results, "... (还有更多结果)")
}
return strings.Join(results, "\n"), nil
}
type ReadFileArgs struct {
Filename string `json:"filename"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func (e *Executor) readFile(args string) (string, error) {
var a ReadFileArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
if a.Limit == 0 {
a.Limit = 100
}
fpath := filepath.Join(e.workspaceDir, a.Filename)
content, err := os.ReadFile(fpath)
if err != nil {
return "", err
}
lines := strings.Split(string(content), "\n")
if a.Offset >= len(lines) {
return "起始位置超出文件行数", nil
}
end := a.Offset + a.Limit
if end > len(lines) {
end = len(lines)
}
result := strings.Join(lines[a.Offset:end], "\n")
if end < len(lines) {
result += fmt.Sprintf("\n\n... (共 %d 行,当前显示 %d-%d)", len(lines), a.Offset+1, end)
}
return result, nil
}
type EditFileArgs struct {
Filename string `json:"filename"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
}
func (e *Executor) editFile(args string) (string, error) {
var a EditFileArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
fpath := filepath.Join(e.workspaceDir, a.Filename)
original, err := os.ReadFile(fpath)
if err != nil {
return "", err
}
if !strings.Contains(string(original), a.OldString) {
return "", fmt.Errorf("文件中未找到要替换的内容")
}
updated := strings.Replace(string(original), a.OldString, a.NewString, 1)
if err := os.WriteFile(fpath, []byte(updated), 0644); err != nil {
return "", err
}
return fmt.Sprintf("已更新文件: %s", a.Filename), nil
}
type WriteFileArgs struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
func (e *Executor) writeFile(args string) (string, error) {
var a WriteFileArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
fpath := filepath.Join(e.workspaceDir, a.Filename)
os.MkdirAll(filepath.Dir(fpath), 0755)
exists := ""
if _, err := os.Stat(fpath); err == nil {
exists = " (已存在,已覆盖)"
}
if err := os.WriteFile(fpath, []byte(a.Content), 0644); err != nil {
return "", err
}
return fmt.Sprintf("已写入文件: %s%s", a.Filename, exists), nil
}
func (e *Executor) listWorkspace() (string, error) {
var files []string
err := filepath.Walk(e.workspaceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
rel, _ := filepath.Rel(e.workspaceDir, path)
if !strings.HasPrefix(rel, ".") {
files = append(files, rel)
}
return nil
})
if err != nil {
return "", err
}
if len(files) == 0 {
return "工作区为空", nil
}
return strings.Join(files, "\n"), nil
}
func (e *Executor) gitStatus() (string, error) {
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = e.workspaceDir
out, err := cmd.Output()
if err != nil {
return "", err
}
if len(out) == 0 {
return "工作区干净,无待提交更改", nil
}
return string(out), nil
}
type GitDiffArgs struct {
Filename string `json:"filename"`
}
func (e *Executor) gitDiff(args string) (string, error) {
var a GitDiffArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
var cmd *exec.Cmd
if a.Filename != "" {
cmd = exec.Command("git", "diff", a.Filename)
} else {
cmd = exec.Command("git", "diff")
}
cmd.Dir = e.workspaceDir
out, err := cmd.Output()
if err != nil {
return "", err
}
if len(out) == 0 {
return "无更改", nil
}
return string(out), nil
}
type GitCommitArgs struct {
Message string `json:"message"`
}
func (e *Executor) gitCommit(args string) (string, error) {
var a GitCommitArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
// git add -A
cmd := exec.Command("git", "add", "-A")
cmd.Dir = e.workspaceDir
if _, err := cmd.Output(); err != nil {
return "", err
}
// git commit
cmd = exec.Command("git", "commit", "-m", a.Message)
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
```
**Step 2: 提交**
```bash
git add internal/room/tools/executor.go
git commit -m "feat: 实现文件操作工具执行器"
```
---
## Task 3: 集成工具到 Room
**Files:**
- Modify: `internal/room/handle.go`
- Modify: `internal/room/types.go`
**Step 1: 修改 types.go 添加工具执行器字段**
在 Room 结构体中添加:
```go
ToolExecutor *tools.Executor
```
**Step 2: 修改 handle.go 初始化工具执行器**
在 Load 函数中添加:
```go
r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace"))
```
**Step 3: 修改 room 调用逻辑以支持工具调用**
在需要的地方(如 build 模式下 Agent 调用),传入工具定义并处理工具调用结果。
**Step 4: 提交**
```bash
git add internal/room/handle.go internal/room/types.go
git commit -m "feat: 集成文件操作工具到 Room"
```
---
## Task 4: 注册 Git 工具定义
**Files:**
- Modify: `internal/room/tools/definitions.go`
**Step 1: 添加 Git 工具定义**
在 FileTools 切片中添加:
```go
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "git_status",
Description: "查看 Git 工作区状态",
Parameters: `{"type": "object", "properties": {}}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "git_diff",
Description: "查看文件变更",
Parameters: `{
"type": "object",
"properties": {
"filename": {"type": "string", "description": "文件名"}
}
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionDefinition{
Name: "git_commit",
Description: "提交更改",
Parameters: `{
"type": "object",
"properties": {
"message": {"type": "string", "description": "提交信息"}
},
"required": ["message"]
}`,
},
},
```
**Step 2: 提交**
```bash
git add internal/room/tools/definitions.go
git commit -m "feat: 添加 Git 工具定义"
```
---
## Task 5: 测试文件操作工具
**Files:**
- Create: `internal/room/tools/executor_test.go`
**Step 1: 创建测试文件**
```go
package tools
import (
"os"
"path/filepath"
"testing"
)
func TestGlob(t *testing.T) {
// 创建临时目录和测试文件
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("package test"), 0644)
os.WriteFile(filepath.Join(tmpDir, "test.ts"), []byte("const x = 1"), 0644)
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
os.WriteFile(filepath.Join(tmpDir, "sub", "nested.go"), []byte("package sub"), 0644)
exec := NewExecutor(tmpDir)
// 测试 *.go
result, err := exec.glob(`{"pattern": "*.go"}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "test.go") {
t.Errorf("expected test.go in result, got: %s", result)
}
// 测试 **/*.go
result, err = exec.glob(`{"pattern": "**/*.go"}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "test.go") || !contains(result, "sub/nested.go") {
t.Errorf("expected test.go and sub/nested.go, got: %s", result)
}
}
func TestReadFile(t *testing.T) {
tmpDir := t.TempDir()
content := "line1\nline2\nline3\nline4\nline5"
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644)
exec := NewExecutor(tmpDir)
// 测试基本读取
result, err := exec.readFile(`{"filename": "test.txt"}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "line1") {
t.Errorf("expected line1 in result, got: %s", result)
}
// 测试 offset 和 limit
result, err = exec.readFile(`{"filename": "test.txt", "offset": 1, "limit": 2}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "line2") || !contains(result, "line3") {
t.Errorf("expected line2 and: %s", result)
}
line3, got}
func TestEditFile(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello world"), 0644)
exec := NewExecutor(tmpDir)
result, err := exec.editFile(`{"filename": "test.txt", "old_string": "world", "new_string": "opencode"}`)
if err != nil {
t.Fatal(err)
}
// 验证文件内容
data, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt"))
if string(data) != "hello opencode" {
t.Errorf("expected 'hello opencode', got: %s", string(data))
}
_ = result // ignore
}
func TestWriteFile(t *testing.T) {
tmpDir := t.TempDir()
exec := NewExecutor(tmpDir)
// 测试新建文件
result, err := exec.writeFile(`{"filename": "new.txt", "content": "new content"}`)
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(tmpDir, "new.txt"))
if string(data) != "new content" {
t.Errorf("expected 'new content', got: %s", string(data))
}
_ = result // ignore
}
func TestListWorkspace(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("a"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("b"), 0644)
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
os.WriteFile(filepath.Join(tmpDir, "sub", "c.txt"), []byte("c"), 0644)
exec := NewExecutor(tmpDir)
result, err := exec.listWorkspace()
if err != nil {
t.Fatal(err)
}
if !contains(result, "a.txt") || !contains(result, "b.txt") || !contains(result, "sub/c.txt") {
t.Errorf("expected all files, got: %s", result)
}
}
func contains(s, substr string) bool {
return len(s) > 0 && strings.Contains(s, substr)
}
```
**Step 2: 运行测试**
```bash
go test ./internal/room/tools/... -v
```
**Step 3: 提交**
```bash
git add internal/room/tools/executor_test.go
git commit -m "test: 添加文件操作工具测试"
```
---
## Plan complete
**Two execution options:**
1. **Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
2. **Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
**Which approach?**

View File

@ -0,0 +1,12 @@
[系统·文档更新调用] 请根据用户的修改要求,重新产出文件《{{.DocName}}》的完整 Markdown 正文。
修改要求:{{.UpdateHint}}
当前文件内容:
{{.CurrentContent}}
要求:
- 以 # {{.DocName}} 开头
- 在现有内容基础上进行修改,保留未涉及的部分
- 只输出文档正文,不要包含任何交流内容
- 系统会自动保存到 workspace/{{.FilePath}}

View File

@ -4,4 +4,6 @@
2. 系统会自动为你发起文档调用,你不需要在聊天中输出文档正文 2. 系统会自动为你发起文档调用,你不需要在聊天中输出文档正文
3. 不要说"请切换到 Build 模式"——你已经在 Build 模式中 3. 不要说"请切换到 Build 模式"——你已经在 Build 模式中
4. 聊天回复只包含:简短的规划说明、任务分配、进度评价 4. 聊天回复只包含:简短的规划说明、任务分配、进度评价
5. 每次只安排一章的写作,完成后等待用户指令再继续下一章
6. 当用户要求修改你负责的文件时,在回复中明确提到文件名(如"已更新《创作需求书》"),系统会自动发起文档更新调用
</mode_constraint> </mode_constraint>

View File

@ -6,3 +6,34 @@
4. 用户说"确认""执行""开始""ok"等执行类指令时,你必须回复:请先切换到 Build 模式再执行 4. 用户说"确认""执行""开始""ok"等执行类指令时,你必须回复:请先切换到 Build 模式再执行
5. 不要说"好的,开始执行"——你在 Plan 模式下无法执行任何操作 5. 不要说"好的,开始执行"——你在 Plan 模式下无法执行任何操作
</mode_constraint> </mode_constraint>
<brainstorm_guide>
你在 Plan 模式下的核心职责是:帮助用户从模糊想法变成清晰可执行的需求。
## 讨论节奏
### 第一步理解1-2轮对话
- 用户给出主题后,先确认你理解了核心意图
- 主动提出 2-3 个关键问题,帮用户明确方向
- 问题要具体(不要问"你有什么要求",而要问"目标受众是谁"、"核心诉求是什么"
### 第二步发散1-2轮对话
- 基于用户回答,提出多个可能的方案或方向
- 每个方案一句话说清核心差异
- 可以提出用户没想到的角度
### 第三步收敛1轮对话
- 总结讨论成果列出已确定的关键要素3-5条
- 明确标注待定项
- 给出你推荐的方案及理由
### 第四步:确认
- 关键要素基本确定后,主动提示:
"讨论已经比较充分了,你可以切换到 Build 模式,我来安排团队执行。"
- 不要过早提示(至少完成理解和收敛),也不要忘记提示
## 注意
- 每次回复 3-5 句话,不要长篇大论
- 主动引导,不要被动等用户提问
- 信息充分时可跳过发散直接收敛
</brainstorm_guide>

View File

@ -1,3 +1,3 @@
[系统] 以下任务被阻止,因为前置阶段尚未完成: [系统] 以下任务被阻止,因为前置材料尚未全部完成:
{{.BlockedList}} {{.BlockedList}}
请先完成当前阶段的工作,再推进下一阶段 请先完成所有前置材料phase:1再开始章节写作

View File

@ -1,16 +1,20 @@
<reminder> <reminder>
⚠️ 必须更新 TodoList.md将刚刚完成的任务标记为 [x]。使用以下格式替换整个文件: 系统会自动检测已产出的文件并勾选对应任务,你不需要手动标记已完成项。
你的职责是在关键节点**细化 TodoList**
- 大纲产出后,将"逐章写作"展开为每章一行,格式:`- [ ] 产出《章节/第N章-标题.md》(@负责人)`
- 新增任务时,使用 <<<REPLACE TodoList.md>>> 格式更新整个文件
- 保留已有的 `- [x]` 完成项,只添加或细化未完成的任务行
- 文件名必须用《》包裹,与实际产出路径一致,系统才能自动勾选
格式示例:
<<<REPLACE TodoList.md>>> <<<REPLACE TodoList.md>>>
# TodoList # TodoList
## Phase N阶段名称 - [x] 产出《创作需求书.md》(@主编)
- [x] 已完成任务描述 (@负责人) - [x] 产出《故事大纲.md》(@主编)
- [ ] 未完成任务描述 (@负责人) - [ ] 产出《章节/第1章-初入江湖.md》(@写手)
- [ ] 产出《章节/第2章-危机四伏.md》(@写手)
- [ ] 告知用户创作完成
<<<END>>> <<<END>>>
要求:
- 严格使用 <<<REPLACE TodoList.md>>> 开头,<<<END>>> 结尾
- 只包含任务列表,不包含任何分配指令或其他对话内容
- 这是强制要求,不可跳过
</reminder> </reminder>

View File

@ -12,6 +12,7 @@ import (
"github.com/sdaduanbilei/agent-team/internal/agent" "github.com/sdaduanbilei/agent-team/internal/agent"
"github.com/sdaduanbilei/agent-team/internal/llm" "github.com/sdaduanbilei/agent-team/internal/llm"
"github.com/sdaduanbilei/agent-team/internal/prompt" "github.com/sdaduanbilei/agent-team/internal/prompt"
"github.com/sdaduanbilei/agent-team/internal/room/tools"
"github.com/sdaduanbilei/agent-team/internal/skill" "github.com/sdaduanbilei/agent-team/internal/skill"
"github.com/sdaduanbilei/agent-team/internal/store" "github.com/sdaduanbilei/agent-team/internal/store"
) )
@ -33,6 +34,8 @@ func Load(roomDir string, agentsDir string, skillsDir string, opts ...LoadOption
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan", Status: StatusPending} r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan", Status: StatusPending}
r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace"))
projectRoot := filepath.Dir(agentsDir) projectRoot := filepath.Dir(agentsDir)
if data, err := os.ReadFile(filepath.Join(projectRoot, "SYSTEM.md")); err == nil { if data, err := os.ReadFile(filepath.Join(projectRoot, "SYSTEM.md")); err == nil {
r.systemRules = string(data) r.systemRules = string(data)
@ -347,7 +350,10 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
board := &SharedBoard{} board := &SharedBoard{}
r.setStatus(StatusWorking, "", "") r.setStatus(StatusWorking, "", "")
r.runMembersParallel(ctx, assignments, board, skillXML) r.runMembersParallel(ctx, assignments, board, skillXML)
r.runChallengeRound(ctx, board, skillXML) // 只在写作阶段(前置材料全部完成后)触发审读
if r.allStaticFilesDone() {
r.runChallengeRound(ctx, board, skillXML)
}
r.setStatus(StatusPending, "", "") r.setStatus(StatusPending, "", "")
return nil return nil
@ -378,6 +384,9 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" { if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" {
extraContext = extraContext + "\n\n" + projectCtx extraContext = extraContext + "\n\n" + projectCtx
} }
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
extraContext = extraContext + "\n\n" + wsCtx
}
systemPrompt := r.master.BuildSystemPrompt(extraContext) systemPrompt := r.master.BuildSystemPrompt(extraContext)
modeInfo := fmt.Sprintf("\n\n当前用户%s\n当前模式%s", userName, r.Mode) modeInfo := fmt.Sprintf("\n\n当前用户%s\n当前模式%s", userName, r.Mode)
if r.Mode == "build" { if r.Mode == "build" {
@ -400,13 +409,13 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
copy(masterMsgs, r.masterHistory) copy(masterMsgs, r.masterHistory)
r.historyMu.Unlock() r.historyMu.Unlock()
// Master 规划循环 // Master 规划循环:简化为最多 3 轮(安全上限),每次用户消息通常只跑 1 轮
executedMembers := make(map[string]int) // 成员名 → 已执行的 phase防止同 phase 重复分配 const maxIterations = 3
for iteration := 0; iteration < 12; iteration++ { for iteration := 0; iteration < maxIterations; iteration++ {
log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration) log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration)
if r.projectTemplate != nil { if r.projectTemplate != nil {
if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration, executedMembers) { if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration) {
break break
} }
} else { } else {
@ -423,7 +432,7 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
// masterCallerDecidedIteration 执行一次 Caller-Decided 路径的 master 迭代。 // masterCallerDecidedIteration 执行一次 Caller-Decided 路径的 master 迭代。
// 返回 true 表示继续循环false 表示 break。 // 返回 true 表示继续循环false 表示 break。
func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int, executedMembers map[string]int) bool { func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int) bool {
if iteration > 0 { if iteration > 0 {
r.setStatus(StatusThinking, r.master.Config.Name, "正在规划...") r.setStatus(StatusThinking, r.master.Config.Name, "正在规划...")
} }
@ -510,22 +519,21 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
if isDocument(reply) && r.allStaticFilesDone() { if isDocument(reply) && r.allStaticFilesDone() {
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name { if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
chapterFilename := r.extractChapterFilename(reply, dynDir) chapterFilename := r.extractChapterFilename(reply, dynDir)
fullPath := dynDir + "/" + chapterFilename
content := strings.TrimSpace(reply) content := strings.TrimSpace(reply)
if !strings.HasPrefix(content, "# ") { if !strings.HasPrefix(content, "# ") {
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
} }
r.saveWorkspace(fullPath, content) r.saveWorkspace(chapterFilename, content)
docName := strings.TrimSuffix(chapterFilename, ".md") docName := strings.TrimSuffix(chapterFilename, ".md")
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName})
if r.Store != nil { if r.Store != nil {
r.Store.InsertMessage(&store.Message{ r.Store.InsertMessage(&store.Message{
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
Content: docName, Filename: fullPath, PartType: "document", Content: docName, Filename: chapterFilename, PartType: "document",
GroupID: &r.currentGroupID, GroupID: &r.currentGroupID,
}) })
} }
log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, fullPath) log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, chapterFilename)
} }
} }
@ -538,18 +546,7 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
} }
} }
// 去重:过滤本轮循环中已执行过同 phase 任务的成员 // Phase 轻量校验只在分配写手phase:2 任务)时检查前置材料是否就绪
for name := range assignments {
if prevPhase, done := executedMembers[name]; done {
targetFile := r.findMemberTargetFile(name)
if targetFile != nil && targetFile.Phase == prevPhase {
log.Printf("[room %s] 跳过重复分配: %sphase %d 已执行)", r.Config.Name, name, prevPhase)
delete(assignments, name)
}
}
}
// Phase 强制校验:阻止跨 phase 分配任务
if len(assignments) > 0 && r.projectTemplate != nil { if len(assignments) > 0 && r.projectTemplate != nil {
blocked := r.validatePhaseAssignments(assignments) blocked := r.validatePhaseAssignments(assignments)
if len(blocked) > 0 { if len(blocked) > 0 {
@ -581,18 +578,12 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
// 执行成员任务 // 执行成员任务
if len(assignments) > 0 && r.Mode == "build" { if len(assignments) > 0 && r.Mode == "build" {
// 标记本轮已执行的成员及其 phase
for name := range assignments {
if tf := r.findMemberTargetFile(name); tf != nil {
executedMembers[name] = tf.Phase
} else {
executedMembers[name] = 0
}
}
board := &SharedBoard{} board := &SharedBoard{}
results := r.runMembersParallel(ctx, assignments, board, skillXML) results := r.runMembersParallel(ctx, assignments, board, skillXML)
r.runChallengeRound(ctx, board, skillXML) // 只在写作阶段(前置材料全部完成后)触发审读
if r.allStaticFilesDone() {
r.runChallengeRound(ctx, board, skillXML)
}
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...") r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
var resultsStr strings.Builder var resultsStr strings.Builder
@ -603,13 +594,9 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
"Results": resultsStr.String(), "Results": resultsStr.String(),
"BoardContext": board.ToContext(), "BoardContext": board.ToContext(),
"WorkspaceContext": r.buildWorkspaceContext(), "WorkspaceContext": r.buildWorkspaceContext(),
"WorkflowStep": r.buildWorkflowStep(), "WorkflowStep": r.buildWorkflowStep(),
}) })
// 提醒更新 TodoList
if r.hasTodoList() {
feedbackMsg += "\n\n" + r.Prompt.R("todo_reminder")
}
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg) feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
*masterMsgs = append(*masterMsgs, feedbackLLMMsg) *masterMsgs = append(*masterMsgs, feedbackLLMMsg)
r.historyMu.Lock() r.historyMu.Lock()
@ -618,9 +605,18 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
r.updateTasks(*masterMsgs) r.updateTasks(*masterMsgs)
} }
// FILE UPDATE CALLS: master 提到要更新已存在的文件
if r.Mode == "build" {
updateFiles := r.parseMasterUpdateIntent(reply)
if len(updateFiles) > 0 {
for _, file := range updateFiles {
r.masterFileUpdateCall(ctx, masterMsgs, file, reply)
}
}
}
// FILE CALLS: master 负责的文件 // FILE CALLS: master 负责的文件
pendingFiles := r.findPendingMasterFiles() pendingFiles := r.findPendingMasterFiles()
chapterWritten := false
if len(pendingFiles) > 0 && r.Mode == "build" { if len(pendingFiles) > 0 && r.Mode == "build" {
for _, file := range pendingFiles { for _, file := range pendingFiles {
r.masterFileCall(ctx, masterMsgs, file) r.masterFileCall(ctx, masterMsgs, file)
@ -630,41 +626,11 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name { if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
// 用 master 的聊天回复作为章节规划提示 // 用 master 的聊天回复作为章节规划提示
r.masterChapterFileCall(ctx, masterMsgs, dynDir, reply) r.masterChapterFileCall(ctx, masterMsgs, dynDir, reply)
chapterWritten = true
} }
} }
// Plan 模式下不循环,每次只回复一条等用户 // 每次用户消息最多执行一轮分配 + 一次 file call然后停下等用户
if r.Mode != "build" { return false
return false
}
// 章节写完后暂停,等用户确认再继续写下一章
if chapterWritten {
return false // break等用户说"继续"
}
// 有成员任务执行过feedbackMsg 已注入(含 workflow step + 下一步指令),直接 continue
// 不再注入 continueMsg避免 master 收到重复的"请分配任务"提示
if len(assignments) > 0 {
return true // continuemaster 根据 feedbackMsg 决定下一步
}
// 只有 file call无成员任务注入 continue 提示
if len(pendingFiles) > 0 {
if stepCtx := r.buildWorkflowStep(); stepCtx != "" {
continueMsg := r.Prompt.Render("continue_next", map[string]string{
"WorkflowStep": stepCtx,
})
continueLLMMsg := llm.NewMsg("user", continueMsg)
*masterMsgs = append(*masterMsgs, continueLLMMsg)
r.historyMu.Lock()
r.masterHistory = append(r.masterHistory, continueLLMMsg)
r.historyMu.Unlock()
}
return true // continue
}
return false // break
} }
// masterLegacyIteration 执行一次旧路径的 master 迭代(无项目模板)。 // masterLegacyIteration 执行一次旧路径的 master 迭代(无项目模板)。
@ -789,7 +755,10 @@ func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Mess
board := &SharedBoard{} board := &SharedBoard{}
results := r.runMembersParallel(ctx, assignments, board, skillXML) results := r.runMembersParallel(ctx, assignments, board, skillXML)
r.runChallengeRound(ctx, board, skillXML) // 只在写作阶段(前置材料全部完成后)触发审读
if r.allStaticFilesDone() {
r.runChallengeRound(ctx, board, skillXML)
}
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...") r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
var resultsStr strings.Builder var resultsStr strings.Builder
@ -901,7 +870,10 @@ func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]st
board := &SharedBoard{} board := &SharedBoard{}
r.runMembersParallel(ctx, assignments, board, skillXML) r.runMembersParallel(ctx, assignments, board, skillXML)
r.runChallengeRound(ctx, board, skillXML) // 只在写作阶段(前置材料全部完成后)触发审读
if r.allStaticFilesDone() {
r.runChallengeRound(ctx, board, skillXML)
}
r.setStatus(StatusPending, "", "") r.setStatus(StatusPending, "", "")
return nil return nil

View File

@ -55,7 +55,7 @@ func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]st
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results) finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
} else if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() { } else if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() {
// 动态章节写作:也是静默文档调用 // 动态章节写作:也是静默文档调用
r.emit(Event{Type: EvtFileWorking, Agent: name, Filename: dynDir + "/", Title: "章节"}) r.emit(Event{Type: EvtFileWorking, Agent: name, Filename: "章节", Title: "章节"})
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results) finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
} else { } else {
// CHAT CALL: 保持现有流式输出逻辑 // CHAT CALL: 保持现有流式输出逻辑
@ -77,9 +77,6 @@ func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]st
} }
wg.Wait() wg.Wait()
// 检查成员间委派
r.runSecondRound(ctx, results, board, tools, skillXML, &mu)
return results return results
} }
@ -133,8 +130,12 @@ func (r *Room) buildMemberContext(name, task string, board *SharedBoard, tools [
taskMsg += "\n\n" + r.Prompt.R("member_update_doc") taskMsg += "\n\n" + r.Prompt.R("member_update_doc")
} }
} }
// Caller-Decided: 预先确定目标文件 // 优先从任务描述中解析目标文件(支持修订已有文件)
targetFile = r.findMemberTargetFile(name) targetFile = r.parseTargetFileFromTask(name, task)
if targetFile == nil {
// Caller-Decided: 预先确定目标文件
targetFile = r.findMemberTargetFile(name)
}
if targetFile != nil { if targetFile != nil {
docName := strings.TrimSuffix(targetFile.Path, ".md") docName := strings.TrimSuffix(targetFile.Path, ".md")
taskMsg += "\n\n" + r.Prompt.Render("file_call_member", map[string]string{ taskMsg += "\n\n" + r.Prompt.Render("file_call_member", map[string]string{

View File

@ -0,0 +1,165 @@
package tools
import "github.com/sashabaranov/go-openai"
var FileTools = []openai.Tool{
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "glob",
Description: "模式匹配查找文件,支持 * ** ? 等通配符",
Parameters: `{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "glob 模式,如 **/*.go, *.ts"
}
},
"required": ["pattern"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "grep",
Description: "在文件中搜索内容,支持正则表达式",
Parameters: `{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "正则表达式搜索模式"
},
"path": {
"type": "string",
"description": "搜索目录路径"
},
"include": {
"type": "string",
"description": "文件过滤,如 *.go, *.ts"
}
},
"required": ["pattern"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "read_file",
Description: "读取文件内容,支持大文件分段读取",
Parameters: `{
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "文件名(相对于 workspace"
},
"offset": {
"type": "integer",
"description": "起始行号(从 0 开始)"
},
"limit": {
"type": "integer",
"description": "读取行数,默认 100"
}
},
"required": ["filename"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "edit_file",
Description: "编辑文件内容,使用字符串替换",
Parameters: `{
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "文件名(相对于 workspace"
},
"old_string": {
"type": "string",
"description": "要替换的原始内容"
},
"new_string": {
"type": "string",
"description": "替换后的新内容"
}
},
"required": ["filename", "old_string", "new_string"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "write_file",
Description: "写入或创建文件",
Parameters: `{
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "文件名(相对于 workspace"
},
"content": {
"type": "string",
"description": "文件内容"
}
},
"required": ["filename", "content"]
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "list_workspace",
Description: "列出工作区的所有文件",
Parameters: `{
"type": "object",
"properties": {}
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "git_status",
Description: "查看 Git 工作区状态",
Parameters: `{"type": "object", "properties": {}}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "git_diff",
Description: "查看文件变更",
Parameters: `{
"type": "object",
"properties": {
"filename": {"type": "string", "description": "文件名"}
}
}`,
},
},
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "git_commit",
Description: "提交更改",
Parameters: `{
"type": "object",
"properties": {
"message": {"type": "string", "description": "提交信息"}
},
"required": ["message"]
}`,
},
},
}

View File

@ -0,0 +1,399 @@
package tools
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/sdaduanbilei/agent-team/internal/llm"
)
type Executor struct {
workspaceDir string
}
func NewExecutor(workspaceDir string) *Executor {
return &Executor{workspaceDir: workspaceDir}
}
func (e *Executor) safePath(filename string) (string, error) {
abs, err := filepath.Abs(filepath.Join(e.workspaceDir, filename))
if err != nil {
return "", err
}
if !strings.HasPrefix(abs, e.workspaceDir) {
return "", fmt.Errorf("path traversal detected: %s", filename)
}
return abs, nil
}
func (e *Executor) Execute(toolCall llm.ToolCall) (string, error) {
switch toolCall.Function.Name {
case "glob":
return e.glob(toolCall.Function.Arguments)
case "grep":
return e.grep(toolCall.Function.Arguments)
case "read_file":
return e.readFile(toolCall.Function.Arguments)
case "edit_file":
return e.editFile(toolCall.Function.Arguments)
case "write_file":
return e.writeFile(toolCall.Function.Arguments)
case "list_workspace":
return e.listWorkspace()
case "git_status":
return e.gitStatus()
case "git_diff":
return e.gitDiff(toolCall.Function.Arguments)
case "git_commit":
return e.gitCommit(toolCall.Function.Arguments)
default:
return "", nil
}
}
type GlobArgs struct {
Pattern string `json:"pattern"`
}
func (e *Executor) glob(args string) (string, error) {
var a GlobArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
_, err := e.safePath(a.Pattern)
if err != nil {
return "", err
}
var result []string
pattern := a.Pattern
if strings.HasPrefix(pattern, "**") {
baseDir := e.workspaceDir
ext := strings.TrimPrefix(pattern, "**/")
err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
matched, _ := filepath.Match(ext, info.Name())
if matched {
abs, _ := filepath.Abs(path)
rel, _ := filepath.Rel(e.workspaceDir, abs)
result = append(result, rel)
}
return nil
})
if err != nil {
return "", err
}
} else {
files, err := filepath.Glob(filepath.Join(e.workspaceDir, pattern))
if err != nil {
return "", err
}
for _, f := range files {
abs, err := filepath.Abs(f)
if err != nil {
continue
}
if !strings.HasPrefix(abs, e.workspaceDir) {
continue
}
rel, _ := filepath.Rel(e.workspaceDir, abs)
result = append(result, rel)
}
}
if len(result) == 0 {
return "未找到匹配的文件", nil
}
return strings.Join(result, "\n"), nil
}
type GrepArgs struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
Include string `json:"include"`
}
func (e *Executor) grep(args string) (string, error) {
var a GrepArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
var err error
searchDir := e.workspaceDir
if a.Path != "" {
searchDir, err = e.safePath(a.Path)
if err != nil {
return "", err
}
}
re, err := regexp.Compile(a.Pattern)
if err != nil {
return "", err
}
var results []string
err = filepath.Walk(searchDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
if a.Include != "" {
matched, _ := filepath.Match(a.Include, info.Name())
if !matched {
return nil
}
}
content, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(content), "\n")
for i, line := range lines {
if re.MatchString(line) {
rel, _ := filepath.Rel(e.workspaceDir, path)
results = append(results, fmt.Sprintf("%s:%d: %s", rel, i+1, line))
}
}
return nil
})
if err != nil {
return "", err
}
if len(results) == 0 {
return "未找到匹配的内容", nil
}
if len(results) > 100 {
results = results[:100]
results = append(results, "... (还有更多结果)")
}
return strings.Join(results, "\n"), nil
}
type ReadFileArgs struct {
Filename string `json:"filename"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func (e *Executor) readFile(args string) (string, error) {
var a ReadFileArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
if a.Limit == 0 {
a.Limit = 100
}
fpath, err := e.safePath(a.Filename)
if err != nil {
return "", err
}
content, err := os.ReadFile(fpath)
if err != nil {
return "", err
}
lines := strings.Split(string(content), "\n")
if a.Offset >= len(lines) {
return "起始位置超出文件行数", nil
}
end := a.Offset + a.Limit
if end > len(lines) {
end = len(lines)
}
result := strings.Join(lines[a.Offset:end], "\n")
if end < len(lines) {
result += fmt.Sprintf("\n\n... (共 %d 行,当前显示 %d-%d)", len(lines), a.Offset+1, end)
}
return result, nil
}
type EditFileArgs struct {
Filename string `json:"filename"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
}
func (e *Executor) editFile(args string) (string, error) {
var a EditFileArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
fpath, err := e.safePath(a.Filename)
if err != nil {
return "", err
}
original, err := os.ReadFile(fpath)
if err != nil {
return "", err
}
if !strings.Contains(string(original), a.OldString) {
return "", fmt.Errorf("文件中未找到要替换的内容,请使用更精确的匹配字符串或使用 write_file 完整覆盖文件")
}
updated := strings.Replace(string(original), a.OldString, a.NewString, 1)
if err := os.WriteFile(fpath, []byte(updated), 0644); err != nil {
return "", err
}
return fmt.Sprintf("已更新文件: %s", a.Filename), nil
}
type WriteFileArgs struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
func (e *Executor) writeFile(args string) (string, error) {
var a WriteFileArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
fpath, err := e.safePath(a.Filename)
if err != nil {
return "", err
}
os.MkdirAll(filepath.Dir(fpath), 0755)
exists := ""
if _, err := os.Stat(fpath); err == nil {
exists = " (已存在,已覆盖)"
}
if err := os.WriteFile(fpath, []byte(a.Content), 0644); err != nil {
return "", err
}
return fmt.Sprintf("已写入文件: %s%s", a.Filename, exists), nil
}
func (e *Executor) listWorkspace() (string, error) {
var files []string
err := filepath.Walk(e.workspaceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
rel, _ := filepath.Rel(e.workspaceDir, path)
if !strings.HasPrefix(rel, ".") {
files = append(files, rel)
}
return nil
})
if err != nil {
return "", err
}
if len(files) == 0 {
return "工作区为空", nil
}
return strings.Join(files, "\n"), nil
}
func (e *Executor) gitStatus() (string, error) {
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = e.workspaceDir
out, err := cmd.Output()
if err != nil {
return "", err
}
if len(out) == 0 {
return "工作区干净,无待提交更改", nil
}
return string(out), nil
}
type GitDiffArgs struct {
Filename string `json:"filename"`
}
func (e *Executor) gitDiff(args string) (string, error) {
var a GitDiffArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
filename := ""
if a.Filename != "" {
fpath, err := e.safePath(a.Filename)
if err != nil {
return "", err
}
filename, _ = filepath.Rel(e.workspaceDir, fpath)
}
var cmd *exec.Cmd
if filename != "" {
cmd = exec.Command("git", "diff", filename)
} else {
cmd = exec.Command("git", "diff")
}
cmd.Dir = e.workspaceDir
out, err := cmd.Output()
if err != nil {
return "", err
}
if len(out) == 0 {
return "无更改", nil
}
return string(out), nil
}
type GitCommitArgs struct {
Message string `json:"message"`
}
func (e *Executor) gitCommit(args string) (string, error) {
var a GitCommitArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
cmd := exec.Command("git", "add", "-A")
cmd.Dir = e.workspaceDir
if _, err := cmd.Output(); err != nil {
return "", err
}
cmd = exec.Command("git", "commit", "-m", a.Message)
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}

View File

@ -0,0 +1,118 @@
package tools
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGlob(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("package test"), 0644)
os.WriteFile(filepath.Join(tmpDir, "test.ts"), []byte("const x = 1"), 0644)
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
os.WriteFile(filepath.Join(tmpDir, "sub", "nested.go"), []byte("package sub"), 0644)
exec := NewExecutor(tmpDir)
result, err := exec.glob(`{"pattern": "*.go"}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "test.go") {
t.Errorf("expected test.go in result, got: %s", result)
}
result, err = exec.glob(`{"pattern": "**/*.go"}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "test.go") || !contains(result, "sub/nested.go") {
t.Errorf("expected test.go and sub/nested.go, got: %s", result)
}
}
func TestReadFile(t *testing.T) {
tmpDir := t.TempDir()
content := "line1\nline2\nline3\nline4\nline5"
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644)
exec := NewExecutor(tmpDir)
result, err := exec.readFile(`{"filename": "test.txt"}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "line1") {
t.Errorf("expected line1 in result, got: %s", result)
}
result, err = exec.readFile(`{"filename": "test.txt", "offset": 1, "limit": 2}`)
if err != nil {
t.Fatal(err)
}
if !contains(result, "line2") || !contains(result, "line3") {
t.Errorf("expected line2 and line3, got: %s", result)
}
}
func TestEditFile(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello world"), 0644)
exec := NewExecutor(tmpDir)
result, err := exec.editFile(`{"filename": "test.txt", "old_string": "world", "new_string": "opencode"}`)
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt"))
if string(data) != "hello opencode" {
t.Errorf("expected 'hello opencode', got: %s", string(data))
}
_ = result
}
func TestWriteFile(t *testing.T) {
tmpDir := t.TempDir()
exec := NewExecutor(tmpDir)
result, err := exec.writeFile(`{"filename": "new.txt", "content": "new content"}`)
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(tmpDir, "new.txt"))
if string(data) != "new content" {
t.Errorf("expected 'new content', got: %s", string(data))
}
_ = result
}
func TestListWorkspace(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("a"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("b"), 0644)
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
os.WriteFile(filepath.Join(tmpDir, "sub", "c.txt"), []byte("c"), 0644)
exec := NewExecutor(tmpDir)
result, err := exec.listWorkspace()
if err != nil {
t.Fatal(err)
}
if !contains(result, "a.txt") || !contains(result, "b.txt") || !contains(result, "sub/c.txt") {
t.Errorf("expected all files, got: %s", result)
}
}
func contains(s, substr string) bool {
return len(s) > 0 && strings.Contains(s, substr)
}

View File

@ -10,6 +10,7 @@ import (
"github.com/sdaduanbilei/agent-team/internal/agent" "github.com/sdaduanbilei/agent-team/internal/agent"
"github.com/sdaduanbilei/agent-team/internal/llm" "github.com/sdaduanbilei/agent-team/internal/llm"
"github.com/sdaduanbilei/agent-team/internal/prompt" "github.com/sdaduanbilei/agent-team/internal/prompt"
"github.com/sdaduanbilei/agent-team/internal/room/tools"
"github.com/sdaduanbilei/agent-team/internal/skill" "github.com/sdaduanbilei/agent-team/internal/skill"
"github.com/sdaduanbilei/agent-team/internal/store" "github.com/sdaduanbilei/agent-team/internal/store"
"github.com/sdaduanbilei/agent-team/internal/user" "github.com/sdaduanbilei/agent-team/internal/user"
@ -39,15 +40,15 @@ type Config struct {
} }
type Room struct { type Room struct {
Config Config Config Config
Dir string Dir string
master *agent.Agent master *agent.Agent
members map[string]*agent.Agent members map[string]*agent.Agent
skillMeta []skill.Meta skillMeta []skill.Meta
User *user.User User *user.User
Status Status Status Status
ActiveAgent string // for working status display ActiveAgent string // for working status display
Broadcast func(Event) // set by api layer Broadcast func(Event) // set by api layer
// master 的会话历史,保持多轮对话上下文 // master 的会话历史,保持多轮对话上下文
masterHistory []llm.Message masterHistory []llm.Message
@ -71,6 +72,8 @@ type Room struct {
Store *store.Store Store *store.Store
currentGroupID int64 // 当前用户消息的 group_id currentGroupID int64 // 当前用户消息的 group_id
ToolExecutor *tools.Executor
cancelFunc func() cancelFunc func()
cancelMu sync.Mutex cancelMu sync.Mutex
} }
@ -90,31 +93,31 @@ const (
EvtScheduleRun EventType = "schedule_run" EvtScheduleRun EventType = "schedule_run"
EvtTokenUsage EventType = "token_usage" EvtTokenUsage EventType = "token_usage"
EvtFileRead EventType = "file_read" EvtFileRead EventType = "file_read"
EvtFileWorking EventType = "file_working" // file-llm 开始生成文件 EvtFileWorking EventType = "file_working" // file-llm 开始生成文件
EvtFileDone EventType = "file_done" // file-llm 文件生成完成 EvtFileDone EventType = "file_done" // file-llm 文件生成完成
) )
type Event struct { type Event struct {
Type EventType `json:"type"` Type EventType `json:"type"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
Agent string `json:"agent,omitempty"` Agent string `json:"agent,omitempty"`
Role string `json:"role,omitempty"` // master | member | challenge Role string `json:"role,omitempty"` // master | member | challenge
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Streaming bool `json:"streaming,omitempty"` Streaming bool `json:"streaming,omitempty"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Task string `json:"task,omitempty"` Task string `json:"task,omitempty"`
Feedback string `json:"feedback,omitempty"` Feedback string `json:"feedback,omitempty"`
Status Status `json:"status,omitempty"` Status Status `json:"status,omitempty"`
ActiveAgent string `json:"active_agent,omitempty"` ActiveAgent string `json:"active_agent,omitempty"`
Action string `json:"action,omitempty"` Action string `json:"action,omitempty"`
Filename string `json:"filename,omitempty"` Filename string `json:"filename,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"` PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"` CompletionTokens int `json:"completion_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"` TotalTokens int `json:"total_tokens,omitempty"`
NoStore bool `json:"-"` // 跳过 emit 中的自动 DB 存储(调用方已显式存储) NoStore bool `json:"-"` // 跳过 emit 中的自动 DB 存储(调用方已显式存储)
} }
// ProjectFile 项目模板中的单个文件 // ProjectFile 项目模板中的单个文件

View File

@ -52,10 +52,10 @@ func (r *Room) buildWorkflowStep() string {
sb.WriteString("</workflow_progress>\n\n") sb.WriteString("</workflow_progress>\n\n")
if minPendingPhase < 999 { if minPendingPhase < 999 {
sb.WriteString(fmt.Sprintf("<next_action>\n当前阶段phase %d\n", minPendingPhase)) sb.WriteString("<next_action>\n当前阶段前置材料准备\n")
sb.WriteString(fmt.Sprintf("⚠️ 严格规则:只能分配 phase %d 的任务,系统会阻止跨阶段分配。\n", minPendingPhase)) sb.WriteString("待产出的前置材料:\n")
for _, f := range r.projectTemplate.Files { for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Phase != minPendingPhase { if f.IsDir || f.Dynamic {
continue continue
} }
fpath := filepath.Join(r.Dir, "workspace", f.Path) fpath := filepath.Join(r.Dir, "workspace", f.Path)
@ -68,24 +68,8 @@ func (r *Room) buildWorkflowStep() string {
sb.WriteString(fmt.Sprintf("- 分配给 @%s%s\n", f.Owner, f.Path)) sb.WriteString(fmt.Sprintf("- 分配给 @%s%s\n", f.Owner, f.Path))
} }
} }
// 显示后续阶段作为提示
for phase := minPendingPhase + 1; phase <= 6; phase++ {
var phaseFiles []string
for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Phase != phase {
continue
}
fpath := filepath.Join(r.Dir, "workspace", f.Path)
if _, err := os.Stat(fpath); os.IsNotExist(err) {
phaseFiles = append(phaseFiles, fmt.Sprintf("%s(@%s)", f.Path, f.Owner))
}
}
if len(phaseFiles) > 0 {
sb.WriteString(fmt.Sprintf("(后续 phase %d%s — 当前阶段完成后自动解锁)\n", phase, strings.Join(phaseFiles, "、")))
}
}
sb.WriteString("</next_action>\n") sb.WriteString("</next_action>\n")
sb.WriteString(fmt.Sprintf("请只分配 phase %d 的任务。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。", minPendingPhase)) sb.WriteString("请安排产出待完成的前置材料。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。")
} else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" { } else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" {
// 所有静态文件已完成,进入动态章节写作阶段 // 所有静态文件已完成,进入动态章节写作阶段
existingChapters := r.listChapterFiles(dynDir) existingChapters := r.listChapterFiles(dynDir)
@ -94,7 +78,7 @@ func (r *Room) buildWorkflowStep() string {
if len(existingChapters) > 0 { if len(existingChapters) > 0 {
sb.WriteString("已完成章节:\n") sb.WriteString("已完成章节:\n")
for _, ch := range existingChapters { for _, ch := range existingChapters {
sb.WriteString(fmt.Sprintf(" [done] %s/%s\n", dynDir, ch)) sb.WriteString(fmt.Sprintf(" [done] %s\n", ch))
} }
} }
isMasterDyn := r.master != nil && dynOwner == r.master.Config.Name isMasterDyn := r.master != nil && dynOwner == r.master.Config.Name
@ -136,40 +120,42 @@ func (r *Room) currentMinPhase() int {
return minPhase return minPhase
} }
// validatePhaseAssignments 校验任务分配是否符合 phase 顺序 // validatePhaseAssignments 轻量校验只在分配写手phase:2 任务时检查前置材料phase:1是否全部就绪
// 返回被阻止的分配及原因 // 返回被阻止的分配及原因
func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string { func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string {
if r.projectTemplate == nil { if r.projectTemplate == nil {
return nil return nil
} }
minPhase := r.currentMinPhase()
if minPhase == 0 { // 检查所有 phase:1 文件是否已完成
return nil // 所有静态文件已完成 allPhase1Done := true
var pendingPhase1 []string
for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Phase != 1 {
continue
}
fpath := filepath.Join(r.Dir, "workspace", f.Path)
if _, err := os.Stat(fpath); os.IsNotExist(err) {
allPhase1Done = false
pendingPhase1 = append(pendingPhase1, fmt.Sprintf("%s(@%s)", f.Path, f.Owner))
}
}
if allPhase1Done {
return nil // 前置材料全部完成,不阻止任何分配
} }
// 只阻止 phase:2写手/章节的分配phase:1 内部不互相阻止
blocked := make(map[string]string) blocked := make(map[string]string)
for name := range assignments { for name := range assignments {
targetFile := r.findMemberTargetFile(name) targetFile := r.findMemberTargetFile(name)
if targetFile == nil { if targetFile == nil {
continue // 没有模板文件,不校验 continue
} }
if targetFile.Phase > minPhase { if targetFile.Phase > 1 {
// 该成员的目标文件 phase 大于当前最小未完成 phase
// 查找当前 phase 还有哪些文件未完成
var pendingInCurrentPhase []string
for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Phase != minPhase {
continue
}
fpath := filepath.Join(r.Dir, "workspace", f.Path)
if _, err := os.Stat(fpath); os.IsNotExist(err) {
pendingInCurrentPhase = append(pendingInCurrentPhase, fmt.Sprintf("%s(@%s)", f.Path, f.Owner))
}
}
blocked[name] = fmt.Sprintf( blocked[name] = fmt.Sprintf(
"《%s》属于 phase:%d phase:%d 还有未完成文件%s", "《%s》属于 phase:%d但前置材料尚未全部完成%s",
targetFile.Path, targetFile.Phase, minPhase, targetFile.Path, targetFile.Phase,
strings.Join(pendingInCurrentPhase, "、")) strings.Join(pendingPhase1, "、"))
} }
} }
return blocked return blocked
@ -214,18 +200,31 @@ func (r *Room) getDynamicFileInfo() (dir, owner string, phase int) {
return "", "", 0 return "", "", 0
} }
// listChapterFiles 列出动态目录下已有的章节文件 // listChapterFiles 列出 workspace 根目录下的章节文件(非模板静态文件的 .md 文件)
func (r *Room) listChapterFiles(dir string) []string { func (r *Room) listChapterFiles(dir string) []string {
chDir := filepath.Join(r.Dir, "workspace", dir) wsDir := filepath.Join(r.Dir, "workspace")
entries, err := os.ReadDir(chDir) entries, err := os.ReadDir(wsDir)
if err != nil { if err != nil {
return nil return nil
} }
// 收集模板静态文件名,用于排除
staticFiles := make(map[string]bool)
if r.projectTemplate != nil {
for _, f := range r.projectTemplate.Files {
if !f.IsDir && !f.Dynamic {
staticFiles[f.Path] = true
}
}
}
var files []string var files []string
for _, e := range entries { for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
files = append(files, e.Name()) continue
} }
if staticFiles[e.Name()] {
continue
}
files = append(files, e.Name())
} }
return files return files
} }
@ -233,7 +232,7 @@ func (r *Room) listChapterFiles(dir string) []string {
// masterChapterFileCall 为 master 发起一次章节文档调用 // masterChapterFileCall 为 master 发起一次章节文档调用
func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) { func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) {
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...") r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...")
r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: dir + "/", Title: "章节"}) r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: "章节", Title: "章节"})
existingChapters := r.listChapterFiles(dir) existingChapters := r.listChapterFiles(dir)
var existingList string var existingList string
@ -269,30 +268,29 @@ func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Mess
content := strings.TrimSpace(reply) content := strings.TrimSpace(reply)
// 从内容中提取章节标题作为文件名 // 从内容中提取章节标题作为文件名(直接保存到 workspace 根目录,不创建子文件夹)
chapterFilename := r.extractChapterFilename(content, dir) chapterFilename := r.extractChapterFilename(content, dir)
fullPath := dir + "/" + chapterFilename
if !strings.HasPrefix(content, "# ") { if !strings.HasPrefix(content, "# ") {
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
} }
r.saveWorkspace(fullPath, content) r.saveWorkspace(chapterFilename, content)
docName := strings.TrimSuffix(chapterFilename, ".md") docName := strings.TrimSuffix(chapterFilename, ".md")
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName})
if r.Store != nil { if r.Store != nil {
r.Store.InsertMessage(&store.Message{ r.Store.InsertMessage(&store.Message{
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
Content: docName, Filename: fullPath, PartType: "document", Content: docName, Filename: chapterFilename, PartType: "document",
GroupID: &r.currentGroupID, GroupID: &r.currentGroupID,
}) })
} }
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName})
// 发送完成状态到聊天 // 发送完成状态到聊天
statusMsg := fmt.Sprintf("《%s》已完成保存到 %s。", docName, fullPath) statusMsg := fmt.Sprintf("《%s》已完成保存到 %s。", docName, chapterFilename)
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true}) r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true})
if r.Store != nil { if r.Store != nil {
r.Store.InsertMessage(&store.Message{ r.Store.InsertMessage(&store.Message{
@ -424,29 +422,14 @@ func (r *Room) findOwnerFiles(agentName string) []ProjectFile {
return files return files
} }
// findPendingMasterFiles 查找当前最小未完成 phase 中 master 负责的待产出文件 // findPendingMasterFiles 查找 master 负责的所有待产出文件(不限 phase
func (r *Room) findPendingMasterFiles() []ProjectFile { func (r *Room) findPendingMasterFiles() []ProjectFile {
if r.projectTemplate == nil { if r.projectTemplate == nil {
return nil return nil
} }
minPhase := 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); os.IsNotExist(err) {
if f.Phase < minPhase {
minPhase = f.Phase
}
}
}
if minPhase == 999 {
return nil
}
var files []ProjectFile var files []ProjectFile
for _, f := range r.projectTemplate.Files { for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Phase != minPhase || f.Owner != r.master.Config.Name { if f.IsDir || f.Dynamic || f.Owner != r.master.Config.Name {
continue continue
} }
fpath := filepath.Join(r.Dir, "workspace", f.Path) fpath := filepath.Join(r.Dir, "workspace", f.Path)
@ -469,11 +452,6 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi
"FilePath": file.Path, "FilePath": file.Path,
}) })
// TodoList 特殊格式要求
if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") {
filePrompt += "\n\n格式要求每个任务项必须包含负责人格式为 `- [ ] 任务描述 (@负责人)`。已完成的用 `- [x]`。"
}
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
filePrompt += "\n\n" + wsCtx filePrompt += "\n\n" + wsCtx
} }
@ -493,11 +471,6 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi
content = "# " + docName + "\n\n" + content content = "# " + docName + "\n\n" + content
} }
// TodoList 特殊处理:去除文件末尾可能混入的 @分配 指令文本
if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") {
content = stripTrailingAssignments(content)
}
r.saveWorkspace(file.Path, content) r.saveWorkspace(file.Path, content)
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
@ -543,6 +516,114 @@ func (r *Room) findMemberTargetFile(name string) *ProjectFile {
return result return result
} }
// parseMasterUpdateIntent 从 master 回复中解析更新文件的意图,返回需要更新的已存在模板文件
func (r *Room) parseMasterUpdateIntent(reply string) []ProjectFile {
if r.projectTemplate == nil {
return nil
}
// 检查 master 回复中是否提到了更新/修改某个已存在的模板文件
// 关键词:已更新、已修改、已调整、更新了、修改了
updateKeywords := []string{"已更新", "已修改", "已调整", "更新了", "修改了", "修改为", "改为", "调整为"}
hasUpdateIntent := false
for _, kw := range updateKeywords {
if strings.Contains(reply, kw) {
hasUpdateIntent = true
break
}
}
if !hasUpdateIntent {
return nil
}
var files []ProjectFile
seen := make(map[string]bool)
for _, f := range r.projectTemplate.Files {
if f.IsDir || f.Dynamic || f.Owner != r.master.Config.Name {
continue
}
fpath := filepath.Join(r.Dir, "workspace", f.Path)
if _, err := os.Stat(fpath); err != nil {
continue // 文件不存在,不是更新场景
}
// 检查 master 是否在回复中提到了这个文件
docName := strings.TrimSuffix(f.Path, ".md")
if strings.Contains(reply, docName) || strings.Contains(reply, "《"+docName+"》") {
if !seen[f.Path] {
seen[f.Path] = true
files = append(files, f)
}
}
}
return files
}
// masterFileUpdateCall 为 master 发起一次文件更新调用(更新已存在的文件)
func (r *Room) masterFileUpdateCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile, updateHint string) {
docName := strings.TrimSuffix(file.Path, ".md")
r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在更新《%s》...", docName))
r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
// 读取现有文件内容
currentContent := ""
fpath := filepath.Join(r.Dir, "workspace", file.Path)
if data, err := os.ReadFile(fpath); err == nil {
currentContent = string(data)
}
filePrompt := r.Prompt.Render("file_call_master_update", map[string]string{
"DocName": docName,
"FilePath": file.Path,
"UpdateHint": updateHint,
"CurrentContent": currentContent,
})
fileLLMMsg := llm.NewMsg("user", filePrompt)
*masterMsgs = append(*masterMsgs, fileLLMMsg)
reply, usage, err := r.master.ChatWithUsage(ctx, *masterMsgs, nil)
if err != nil {
log.Printf("[room %s] master file update call error for %s: %v", r.Config.Name, file.Path, err)
return
}
r.emitUsage(r.master.Config.Name, usage)
content := strings.TrimSpace(reply)
if !strings.HasPrefix(content, "# ") {
content = "# " + docName + "\n\n" + content
}
r.saveWorkspace(file.Path, content)
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
if r.Store != nil {
r.Store.InsertMessage(&store.Message{
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
Content: docName, Filename: file.Path, PartType: "document",
GroupID: &r.currentGroupID,
})
}
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
// 发送完成状态到聊天
statusMsg := fmt.Sprintf("《%s》已更新。", docName)
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true})
if r.Store != nil {
r.Store.InsertMessage(&store.Message{
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
Content: statusMsg, PartType: "text",
GroupID: &r.currentGroupID,
})
}
assistantMsg := llm.NewMsg("assistant", reply)
*masterMsgs = append(*masterMsgs, assistantMsg)
r.historyMu.Lock()
r.masterHistory = append(r.masterHistory, fileLLMMsg, assistantMsg)
r.historyMu.Unlock()
r.AppendHistory("master", r.master.Config.Name, reply)
}
// buildSkillSummary 为 master 构建简要的 skill 清单 // buildSkillSummary 为 master 构建简要的 skill 清单
func (r *Room) buildSkillSummary() string { func (r *Room) buildSkillSummary() string {
if len(r.skillMeta) == 0 { if len(r.skillMeta) == 0 {

View File

@ -80,6 +80,58 @@ func (r *Room) buildWorkspaceContext() string {
}) })
} }
// syncTodoList 扫描 TodoList.md自动将已产出文件的任务勾选为 [x]
// 同时修正路径:如果文件名带子目录前缀但实际文件在 workspace 根目录,自动去掉前缀
func (r *Room) syncTodoList() {
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
data, err := os.ReadFile(fpath)
if err != nil {
return
}
fileRe := regexp.MustCompile(`《(.+?)》`)
lines := strings.Split(string(data), "\n")
changed := false
for i, line := range lines {
// 修正路径:所有行(不限 [ ] / [x])中带子目录前缀的文件名,如果实际文件在根目录则去掉前缀
for _, m := range fileRe.FindAllStringSubmatch(line, -1) {
filename := m[1]
baseName := filepath.Base(filename)
if baseName != filename {
fpSub := filepath.Join(r.Dir, "workspace", filename)
fpFlat := filepath.Join(r.Dir, "workspace", baseName)
// 子目录下不存在但根目录下存在 → 修正路径
if _, err := os.Stat(fpSub); os.IsNotExist(err) {
if _, err := os.Stat(fpFlat); err == nil {
lines[i] = strings.Replace(lines[i], "《"+filename+"》", "《"+baseName+"》", 1)
changed = true
}
}
}
}
// 勾选:检查未完成任务对应的文件是否已存在
trimmed := strings.TrimSpace(lines[i])
if !strings.HasPrefix(trimmed, "- [ ]") {
continue
}
m := fileRe.FindStringSubmatch(lines[i])
if m == nil {
continue
}
filename := m[1]
fp := filepath.Join(r.Dir, "workspace", filename)
if _, err := os.Stat(fp); err == nil {
lines[i] = strings.Replace(lines[i], "- [ ]", "- [x]", 1)
changed = true
}
}
if changed {
os.WriteFile(fpath, []byte(strings.Join(lines, "\n")), 0644)
r.emit(Event{Type: EvtWorkspaceFile, Filename: "TodoList.md", Action: "updated"})
}
}
// hasTodoList 检查 workspace 中是否存在 TodoList.md // hasTodoList 检查 workspace 中是否存在 TodoList.md
func (r *Room) hasTodoList() bool { func (r *Room) hasTodoList() bool {
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md") fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
@ -87,6 +139,16 @@ func (r *Room) hasTodoList() bool {
return err == nil return err == nil
} }
// hasPendingTodo 检查 TodoList.md 中是否还有未完成的任务(`- [ ]`
func (r *Room) hasPendingTodo() bool {
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
data, err := os.ReadFile(fpath)
if err != nil {
return false
}
return strings.Contains(string(data), "- [ ]")
}
// saveAgentOutput 统一处理成员产出的文件保存路由(旧路径兼容,无模板时使用)。 // saveAgentOutput 统一处理成员产出的文件保存路由(旧路径兼容,无模板时使用)。
// 返回 (filename, routed)routed=true 表示走了文件路由。 // 返回 (filename, routed)routed=true 表示走了文件路由。
func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) { func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
@ -151,22 +213,20 @@ func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() { if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() {
if isDocument(finalReply) { if isDocument(finalReply) {
chapterFilename := r.extractChapterFilename(finalReply, dynDir) chapterFilename := r.extractChapterFilename(finalReply, dynDir)
fullPath := dynDir + "/" + chapterFilename
content := strings.TrimSpace(finalReply) content := strings.TrimSpace(finalReply)
if !strings.HasPrefix(content, "# ") { if !strings.HasPrefix(content, "# ") {
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
} }
os.MkdirAll(filepath.Join(r.Dir, "workspace", dynDir), 0755) r.saveWorkspace(chapterFilename, content)
r.saveWorkspace(fullPath, content)
if r.memberArtifacts == nil { if r.memberArtifacts == nil {
r.memberArtifacts = make(map[string]string) r.memberArtifacts = make(map[string]string)
} }
r.memberArtifacts[name] = fullPath r.memberArtifacts[name] = chapterFilename
docName := strings.TrimSuffix(chapterFilename, ".md") docName := strings.TrimSuffix(chapterFilename, ".md")
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: fullPath, Title: docName}) r.emit(Event{Type: EvtArtifact, Agent: name, Filename: chapterFilename, Title: docName})
r.emit(Event{Type: EvtFileDone, Agent: name, Filename: fullPath, Title: docName}) r.emit(Event{Type: EvtFileDone, Agent: name, Filename: chapterFilename, Title: docName})
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task}) r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
return fullPath, true return chapterFilename, true
} }
} }
@ -285,6 +345,37 @@ func splitContentAndStatus(reply, filename string) (fileContent, statusMsg strin
return return
} }
// parseTargetFileFromTask 从任务描述中解析《文件名》,匹配 workspace 已有文件(支持修订已有文件)
func (r *Room) parseTargetFileFromTask(memberName, task string) *ProjectFile {
re := regexp.MustCompile(`《(.+?)》`)
matches := re.FindAllStringSubmatch(task, -1)
for _, m := range matches {
filename := m[1]
if !strings.HasSuffix(filename, ".md") {
filename += ".md"
}
fpath := filepath.Join(r.Dir, "workspace", filename)
if _, err := os.Stat(fpath); err == nil {
// 文件存在 → 优先匹配模板定义(保留正确的 Owner/Phase
if tf := r.matchTemplateFile(strings.TrimSuffix(filename, ".md")); tf != nil {
// 只有文件负责人才能写入
if tf.Owner == memberName {
return tf
}
continue
}
// 模板中没有 → 构造虚拟 ProjectFile
return &ProjectFile{
Path: filename,
Owner: memberName,
Phase: 2,
Dynamic: true,
}
}
}
return nil
}
// applyDocumentEdit 解析并应用文档编辑指令 // applyDocumentEdit 解析并应用文档编辑指令
// 支持两种格式: // 支持两种格式:
// 1. 全文替换:<<<REPLACE filename>>>\n新内容\n<<<END>>> // 1. 全文替换:<<<REPLACE filename>>>\n新内容\n<<<END>>>

View File

@ -1,5 +1,5 @@
# Project: Agent Team # Project: Agent Team
Last updated: 2026-03-05 Last updated: 2026-03-10
## Pinned (关键约束) ## Pinned (关键约束)
- 一切皆 MD — agent 配置、soul、memory、tasks、history 全部是 Markdown 文件 - 一切皆 MD — agent 配置、soul、memory、tasks、history 全部是 Markdown 文件
@ -20,6 +20,11 @@ Last updated: 2026-03-05
- 暂无 - 暂无
## Done ## Done
- 2026-03-10Task 5 测试文件操作工具
- 创建 internal/room/tools/executor_test.go 测试文件
- 修复 executor.go glob 函数支持 `**/*.go` 递归匹配
- 代码变更:`files changed: 2`, `insertions: +154`, `deletions: -13`
- Evidence: internal/room/tools/executor_test.go (new), internal/room/tools/executor.go
- 2026-03-05支持从 TEAM.md 读取团队元信息 - 2026-03-05支持从 TEAM.md 读取团队元信息
- 修改 internal/hub/hub.go添加 readTeamMeta 函数 - 修改 internal/hub/hub.go添加 readTeamMeta 函数
- 安装团队时从 TEAM.md 解析 name/description/author - 安装团队时从 TEAM.md 解析 name/description/author

View File

@ -1 +1 @@
1ee6643f-5997-464c-b8b1-95d34fe1f0a5 a79aa3b2-59fb-44e8-ab40-eb6da1803f3d

View File

@ -1,7 +1,7 @@
# 我的简介 # 我的简介
## 我是谁 ## 我是谁
我是[ 我是[你的名字][职业/背景]。
## 工作风格 ## 工作风格
- 喜欢[沟通方式] - 喜欢[沟通方式]

View File

@ -1,6 +1,6 @@
--- ---
name: 第五季 name: 第五季
description: 产品经理,擅长需求分析 description: 产品经理
provider: deepseek provider: deepseek
model: deepseek-chat model: deepseek-chat
api_key_env: DEEPSEEK_API_KEY api_key_env: DEEPSEEK_API_KEY

View File

@ -33,7 +33,7 @@ function formatTime(ts: string): string {
} }
export function ChatView() { export function ChatView() {
const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems, workingFiles } = useStore() const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems, workingFiles, workStartedAt } = useStore()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [mentionQuery, setMentionQuery] = useState<string | null>(null) const [mentionQuery, setMentionQuery] = useState<string | null>(null)
const [mentionIndex, setMentionIndex] = useState(0) const [mentionIndex, setMentionIndex] = useState(0)
@ -193,6 +193,7 @@ export function ChatView() {
todos={activeRoomId ? (todoItems[activeRoomId] || []) : []} todos={activeRoomId ? (todoItems[activeRoomId] || []) : []}
room={room ? { status: room.status, activeAgent: room.activeAgent, action: room.action, master: room.master } : undefined} room={room ? { status: room.status, activeAgent: room.activeAgent, action: room.action, master: room.master } : undefined}
workingFiles={activeRoomId ? (workingFiles[activeRoomId] || []) : []} workingFiles={activeRoomId ? (workingFiles[activeRoomId] || []) : []}
startedAt={activeRoomId ? workStartedAt[activeRoomId] : undefined}
/> />
<div className="relative"> <div className="relative">
{/* @ Mention dropdown */} {/* @ Mention dropdown */}
@ -471,7 +472,20 @@ function MarqueeText({ text, className }: { text: string; className?: string })
) )
} }
function AgentStatusBar({ todos, room, workingFiles = [] }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string }; workingFiles?: { agent: string; filename: string; title?: string }[] }) { function ElapsedTimer({ startedAt }: { startedAt: number }) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000)
return () => clearInterval(id)
}, [])
const elapsed = Math.floor((Date.now() - startedAt) / 1000)
const m = Math.floor(elapsed / 60)
const s = elapsed % 60
const text = m > 0 ? `${m}m${s.toString().padStart(2, '0')}s` : `${s}s`
return <span className="text-xs opacity-50 tabular-nums">{text}</span>
}
function AgentStatusBar({ todos, room, workingFiles = [], startedAt }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string }; workingFiles?: { agent: string; filename: string; title?: string }[]; startedAt?: number }) {
// 只在 master 本人是活跃 agent 或正在思考时显示 master 状态 // 只在 master 本人是活跃 agent 或正在思考时显示 master 状态
const showMaster = room && ( const showMaster = room && (
room.status === 'thinking' || room.status === 'thinking' ||
@ -481,6 +495,7 @@ function AgentStatusBar({ todos, room, workingFiles = [] }: { todos: TodoItem[];
return ( return (
<div className="flex items-center gap-2 mb-2 px-1 py-1.5 overflow-hidden flex-wrap"> <div className="flex items-center gap-2 mb-2 px-1 py-1.5 overflow-hidden flex-wrap">
{startedAt && <ElapsedTimer startedAt={startedAt} />}
{/* 文件生成状态 */} {/* 文件生成状态 */}
{workingFiles.map(f => ( {workingFiles.map(f => (
<div <div

View File

@ -27,6 +27,7 @@ interface AppState {
todoItems: Record<string, TodoItem[]> todoItems: Record<string, TodoItem[]>
fileReaders: Record<string, Record<string, string[]>> fileReaders: Record<string, Record<string, string[]>>
workingFiles: Record<string, { agent: string; filename: string; title?: string }[]> workingFiles: Record<string, { agent: string; filename: string; title?: string }[]>
workStartedAt: Record<string, number> // roomId -> timestamp (ms)
setTheme: (theme: 'light' | 'dark') => void setTheme: (theme: 'light' | 'dark') => void
toggleTheme: () => void toggleTheme: () => void
@ -83,6 +84,7 @@ export const useStore = create<AppState>((set, get) => {
todoItems: {}, todoItems: {},
fileReaders: {}, fileReaders: {},
workingFiles: {}, workingFiles: {},
workStartedAt: {},
setTheme: (theme) => { setTheme: (theme) => {
applyTheme(theme) applyTheme(theme)
@ -257,6 +259,13 @@ export const useStore = create<AppState>((set, get) => {
: t : t
) )
} }
// 记录工作开始时间thinking/working 时设置pending 时清除)
const workStartedAt = { ...s.workStartedAt }
if (ev.status === 'pending') {
delete workStartedAt[roomId]
} else if (!workStartedAt[roomId]) {
workStartedAt[roomId] = Date.now()
}
// pending 时:清理所有残留 streaming 消息,只在所有任务都 done 后清空 todos // pending 时:清理所有残留 streaming 消息,只在所有任务都 done 后清空 todos
if (ev.status === 'pending') { if (ev.status === 'pending') {
const cleanMsgs = (s.messages[roomId] || []).map(m => const cleanMsgs = (s.messages[roomId] || []).map(m =>
@ -273,6 +282,7 @@ export const useStore = create<AppState>((set, get) => {
messages: { ...s.messages, [roomId]: cleanMsgs }, messages: { ...s.messages, [roomId]: cleanMsgs },
todoItems: { ...s.todoItems, [roomId]: todos }, todoItems: { ...s.todoItems, [roomId]: todos },
workingFiles: { ...s.workingFiles, [roomId]: [] }, workingFiles: { ...s.workingFiles, [roomId]: [] },
workStartedAt,
} }
} }
return { return {
@ -280,6 +290,7 @@ export const useStore = create<AppState>((set, get) => {
? { ...r, status: ev.status, activeAgent: ev.active_agent, action: ev.action } ? { ...r, status: ev.status, activeAgent: ev.active_agent, action: ev.action }
: r), : r),
todoItems: { ...s.todoItems, [roomId]: todos }, todoItems: { ...s.todoItems, [roomId]: todos },
workStartedAt,
} }
}) })
} else if (ev.type === 'tasks_update') { } else if (ev.type === 'tasks_update') {