diff --git a/internal/app/app.go b/internal/app/app.go index 6ae6335..17fc315 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ import ( "time" agentllm "agent-native/runtime/llm" + agentmcp "agent-native/runtime/mcp" agenttools "agent-native/runtime/tools" squad "agent-squad/squad" "agent-system-backend/internal/bus" @@ -33,6 +34,7 @@ import ( "agent-system-backend/internal/runtime" "agent-system-backend/internal/server" "agent-system-backend/internal/settings" + "agent-system-backend/internal/skills" "agent-system-backend/internal/squadadapter" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" @@ -110,10 +112,18 @@ func Run(opt Options) error { body, err := promptSvc.AgentPrompt(store.OwnerDefault, "agent.squad."+kind) return body, err == nil } + // 技能种子:内置三套交付配方(docx/xlsx/pptx-author)落 /skills/, + // 幂等且不覆盖用户修订;agent-native 内核与小队角色声明都依赖它在。 + skillsRoot := filepath.Join(opt.DataDir, "skills") + if n, serr := skills.Seed(skillsRoot); serr != nil { + log.Printf("skills: seed builtin recipes failed: %v", serr) + } else if n > 0 { + log.Printf("skills: seeded %d builtin recipes into %s", n, skillsRoot) + } // 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析): - // 模型走 settings.models.chat 同一路由;搜索/解释器复用产品同一套配置与白名单; - // 技能根取数据目录 skills/(存在才注入)。主模型未配置时返回未启用(不阻塞产品)。 - squadadapter.AgentNativeOptions = func() (squad.AgentNativeConfig, bool) { + // 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置; + // 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。 + squadadapter.AgentNativeOptions = func(rc squadadapter.Ctx) (squad.AgentNativeConfig, bool) { effNow := setSvc.Effective(store.OwnerDefault, "") route := runtime.ResolveChatModel(core.AgentProfile{}, effNow) if route.Model == "" || route.Base == "" { @@ -124,6 +134,7 @@ func Run(opt Options) error { DefaultModel: route.Model, MaxTurns: 12, Timeout: 10 * time.Minute, + Subagent: squadadapter.SubagentFactory(rc), } if keys := splitKeyPool(effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 { cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys) @@ -131,10 +142,12 @@ func Run(opt Options) error { if effNow.GetBool("runtime.exec", true) { cfg.Exec = tools.InterpreterExecConfig() } - skillsRoot := filepath.Join(opt.DataDir, "skills") if st2, err := os.Stat(skillsRoot); err == nil && st2.IsDir() { cfg.SkillsRoots = []string{skillsRoot} } + if mgr := mcpManagerFor(strings.TrimSpace(effNow.GetString("tools.mcp.servers", ""))); mgr != nil { + cfg.MCP = mgr + } return cfg, true } profSvc := profiles.New(st) @@ -381,6 +394,47 @@ func cachedInt(ttl time.Duration, fn func() int) func() int { } // splitKeyPool 拆逗号分隔密钥池(trim 空段)。 +// mcpCache 按配置串缓存 MCP Manager:配置不变复用同一实例(后端解析器 +// per-role 缓存会长期持有它,进程型 server 不重复拉起);配置变更时关闭旧 +// Manager(杀掉其进程型 server)再重建。配置错误返回 nil 并留日志—— +// MCP 是可选能力,坏配置禁用该能力而不是阻塞产品启动。 +var ( + mcpMu sync.Mutex + mcpKey string + mcpManager *agentmcp.Manager +) + +func mcpManagerFor(raw string) *agentmcp.Manager { + mcpMu.Lock() + defer mcpMu.Unlock() + if raw == "" { + if mcpManager != nil { + mcpManager.Close() + mcpManager = nil + } + mcpKey = "" + return nil + } + if raw == mcpKey && mcpManager != nil { + return mcpManager + } + var servers []agentmcp.ServerConfig + if err := json.Unmarshal([]byte(raw), &servers); err != nil { + log.Printf("squad agent-native: tools.mcp.servers is not valid JSON, MCP disabled: %v", err) + return nil + } + mgr, err := agentmcp.NewManager(servers) + if err != nil { + log.Printf("squad agent-native: MCP config invalid, MCP disabled: %v", err) + return nil + } + if mcpManager != nil { + mcpManager.Close() + } + mcpKey, mcpManager = raw, mgr + return mgr +} + func splitKeyPool(s string) []string { var out []string for _, k := range strings.Split(s, ",") { diff --git a/internal/app/mcp_cache_test.go b/internal/app/mcp_cache_test.go new file mode 100644 index 0000000..56ef5d3 --- /dev/null +++ b/internal/app/mcp_cache_test.go @@ -0,0 +1,73 @@ +package app + +import ( + "sync" + "testing" +) + +func resetMcpCache() { + mcpMu.Lock() + defer mcpMu.Unlock() + if mcpManager != nil { + mcpManager.Close() + } + mcpKey, mcpManager = "", nil +} + +func TestMcpManagerForCacheAndRebuild(t *testing.T) { + resetMcpCache() + defer resetMcpCache() + + cfgA := `[{"name":"a","command":"/bin/true"}]` + cfgB := `[{"name":"b","command":"/bin/true"}]` + + m1 := mcpManagerFor(cfgA) + if m1 == nil { + t.Fatal("valid config must build a manager") + } + if m2 := mcpManagerFor(cfgA); m2 != m1 { + t.Fatal("same config must reuse the cached manager") + } + m3 := mcpManagerFor(cfgB) + if m3 == nil || m3 == m1 { + t.Fatal("changed config must rebuild a fresh manager") + } + if mcpManagerFor("") != nil { + t.Fatal("empty config disables MCP and clears the cache") + } + if mcpManagerFor(`{bad json`) != nil { + t.Fatal("invalid JSON must disable MCP (nil), not panic") + } + if mcpManagerFor(`[{"name":"bad name","command":"x"}]`) != nil { + t.Fatal("invalid server config must disable MCP (nil)") + } + // 并发调用不死锁、返回同一实例或各自重建(缓存一致性由锁保证)。 + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = mcpManagerFor(cfgB) + }() + } + wg.Wait() +} + +func TestMcpCacheConcurrencyWithSettingsResolution(t *testing.T) { + resetMcpCache() + defer resetMcpCache() + var wg sync.WaitGroup + cfgs := []string{ + `[{"name":"a","command":"/bin/true"}]`, + `[{"name":"b","command":"/bin/true"}]`, + "", + } + for i := 0; i < 12; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _ = mcpManagerFor(cfgs[i%len(cfgs)]) + }(i) + } + wg.Wait() +} diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index 4329fb4..01ef4a7 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -11,25 +11,13 @@ description: 生成文档/表格/演示稿等文件交付物的通用工作流 2. **登记**:调用 register_artifact登记生成文件(path 传相对路径);登记后对话内自动出现可预览的文件卡片。 3. **收尾**:对话回复只给结论摘要与要点(几句话),全文进文件,不要在回复里复述文档内容;也不要只口头说文件名——卡片就是交付。 -配方(按需选用;缺库先 `run_shell: pip install --user python-docx` 装好再跑): +配方已迁入技能库(启动时种子到数据目录 skills/,完整 Python 配方见对应 SKILL.md): -- **Word(.docx)**:python-docx。骨架: - ```python - from docx import Document - from docx.shared import Pt, RGBColor - from docx.enum.text import WD_ALIGN_PARAGRAPH - doc = Document() - doc.styles['Normal'].font.name = 'Calibri'; doc.styles['Normal']._element.rPr.rFonts.set(__import__('docx.oxml.ns', fromlist=['qn']).qn('w:eastAsia'), '等线') - t = doc.add_heading('文档标题', level=0); t.alignment = WD_ALIGN_PARAGRAPH.CENTER - doc.add_heading('一、章节', level=1) - doc.add_paragraph('正文段落……') - table = doc.add_table(rows=1, cols=3); table.style = 'Light Grid Accent 1' - for i, h in enumerate(['列一','列二','列三']): table.rows[0].cells[i].text = h - doc.add_paragraph('要点', style='List Bullet'); doc.add_paragraph('结论', style='List Number') - doc.save('交付物名.docx') - ``` -- **Excel(.xlsx)**:openpyxl。骨架:`from openpyxl import Workbook; wb=Workbook(); ws=wb.active; ws.title='表1'; ws.append(['列一','列二']); ws.append([1,2]); ws.column_dimensions['A'].width=18; wb.save('名.xlsx')`。 -- **PPT(.pptx)**:python-pptx。骨架:`from pptx import Presentation; prs=Presentation(); s=prs.slides.add_slide(prs.slide_layouts[0]); s.shapes.title.text='标题'; s.placeholders[1].text='副标题'; prs.save('名.pptx')`。 +- **docx-author**:python-docx——标题层级/表格/列表真实排版。 +- **xlsx-author**:openpyxl——表头格式/列宽/公式/多表。 +- **pptx-author**:python-pptx——标题页/内容页要点/分节页。 +- 小队 agent-native 角色经 load_skill 按名加载;其余路径按需 `run_shell` 查看技能文件内容(如 `cat skills/docx-author/SKILL.md`)。 +- 最小导入行备查:`from docx import Document` / `from openpyxl import Workbook` / `from pptx import Presentation`;缺库先 `pip install --user python-docx openpyxl python-pptx`。 要求与边界: diff --git a/internal/skills/recipes/docx-author/SKILL.md b/internal/skills/recipes/docx-author/SKILL.md new file mode 100644 index 0000000..3d435f3 --- /dev/null +++ b/internal/skills/recipes/docx-author/SKILL.md @@ -0,0 +1,42 @@ +--- +description: 用 python-docx 生成带真实排版的 Word(.docx)交付物 +--- + +# docx 生成配方(python-docx) + +先装库(缺库时):`pip install --user python-docx`。经 run_python 执行,文件落在会话工作区。 + +```python +from docx import Document +from docx.shared import Pt, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn + +doc = Document() +# 全局中文字体:等线(西文 Calibri) +style = doc.styles['Normal'] +style.font.name = 'Calibri' +style._element.rPr.rFonts.set(qn('w:eastAsia'), '等线') +style.font.size = Pt(11) + +t = doc.add_heading('文档标题', level=0) +t.alignment = WD_ALIGN_PARAGRAPH.CENTER + +doc.add_heading('一、章节标题', level=1) +doc.add_paragraph('正文段落……') + +# 表格(带样式) +table = doc.add_table(rows=1, cols=3) +table.style = 'Light Grid Accent 1' +for i, h in enumerate(['列一', '列二', '列三']): + table.rows[0].cells[i].text = h +table.add_row().cells[0].text = '数据' + +# 列表 +doc.add_paragraph('要点', style='List Bullet') +doc.add_paragraph('结论', style='List Number') + +doc.save('交付物名.docx') +``` + +要求:标题层级、表格、列表样式必须真实排版;生成后 register_artifact 登记(path 传相对路径);修订生成新文件名而不是覆盖。 diff --git a/internal/skills/recipes/pptx-author/SKILL.md b/internal/skills/recipes/pptx-author/SKILL.md new file mode 100644 index 0000000..f9aa089 --- /dev/null +++ b/internal/skills/recipes/pptx-author/SKILL.md @@ -0,0 +1,37 @@ +--- +description: 用 python-pptx 生成标题页与内容页结构的 PowerPoint(.pptx)交付物 +--- + +# pptx 生成配方(python-pptx) + +先装库(缺库时):`pip install --user python-pptx`。经 run_python 执行,文件落在会话工作区。 + +```python +from pptx import Presentation +from pptx.util import Inches, Pt + +prs = Presentation() + +# 标题页(版式 0:标题+副标题) +cover = prs.slides.add_slide(prs.slide_layouts[0]) +cover.shapes.title.text = '汇报标题' +cover.placeholders[1].text = '副标题 / 日期 / 出品' + +# 内容页(版式 1:标题+正文):标题 + 项目符号列表 +s = prs.slides.add_slide(prs.slide_layouts[1]) +s.shapes.title.text = '章节要点' +body = s.placeholders[1].text_frame +body.text = '第一点:结论先行' +for point in ['第二点:数据支撑', '第三点:风险与建议']: + p = body.add_paragraph() + p.text = point + p.level = 0 + +# 分节页(版式 5:仅标题) +sec = prs.slides.add_slide(prs.slide_layouts[5]) +sec.shapes.title.text = '第二部分' + +prs.save('交付物名.pptx') +``` + +要求:每页一个明确论点、要点不超过 5 条;生成后 register_artifact 登记;修订生成新文件名。 diff --git a/internal/skills/recipes/xlsx-author/SKILL.md b/internal/skills/recipes/xlsx-author/SKILL.md new file mode 100644 index 0000000..7535269 --- /dev/null +++ b/internal/skills/recipes/xlsx-author/SKILL.md @@ -0,0 +1,37 @@ +--- +description: 用 openpyxl 生成带格式与公式的 Excel(.xlsx)交付物 +--- + +# xlsx 生成配方(openpyxl) + +先装库(缺库时):`pip install --user openpyxl`。经 run_python 执行,文件落在会话工作区。 + +```python +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill + +wb = Workbook() +ws = wb.active +ws.title = '表1' + +# 表头(加粗 + 底色 + 居中) +header_font = Font(bold=True, color='FFFFFF') +header_fill = PatternFill('solid', fgColor='4472C4') +for col, name in enumerate(['列一', '列二', '列三'], start=1): + c = ws.cell(row=1, column=col, value=name) + c.font = header_font + c.fill = header_fill + c.alignment = Alignment(horizontal='center') + +ws.append([None, None, None]) # 对齐表头后追加数据行 +ws.append(['甲', 100, '=B2*2']) # 公式直接写字符串 +ws.column_dimensions['A'].width = 18 + +# 第二个工作表 +ws2 = wb.create_sheet('说明') +ws2['A1'] = '口径与数据来源说明' + +wb.save('交付物名.xlsx') +``` + +要求:表头格式、列宽、公式与多表结构按内容需要使用;生成后 register_artifact 登记;修订生成新文件名。 diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 0000000..5fd4894 --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,67 @@ +// Package skills 内置技能库:go:embed 三套交付物生成配方(docx/xlsx/pptx), +// 应用启动时种子到 /skills/,供 agent-native 内核的技能加载器 +// (SKILL.md + load_skill 渐进披露)与人工查阅使用。 +// 种子幂等且不覆盖:已存在的 SKILL.md 视为用户修订,原样保留。 +package skills + +import ( + "embed" + "io/fs" + "os" + "path/filepath" + "sort" +) + +//go:embed recipes +var recipes embed.FS + +// Seed 把内置技能种子到 dir(幂等;已存在的文件不覆盖)。 +// 返回本次实际写入的文件数。 +func Seed(dir string) (int, error) { + count := 0 + err := fs.WalkDir(recipes, "recipes", func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel("recipes", path) + if err != nil { + return err + } + dst := filepath.Join(dir, filepath.FromSlash(rel)) + if _, statErr := os.Stat(dst); statErr == nil { + return nil // 用户修订过:不覆盖 + } + data, err := recipes.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + return err + } + count++ + return nil + }) + return count, err +} + +// Names 返回内置技能名(目录名,排序稳定)。 +func Names() []string { + entries, err := fs.ReadDir(recipes, "recipes") + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 0000000..ee7d0e3 --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,44 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSeedIdempotentAndNoOverwrite(t *testing.T) { + dir := t.TempDir() + n, err := Seed(dir) + if err != nil || n != 3 { + t.Fatalf("first seed want 3 files, got %d err=%v", n, err) + } + for _, name := range []string{"docx-author", "xlsx-author", "pptx-author"} { + data, err := os.ReadFile(filepath.Join(dir, name, "SKILL.md")) + if err != nil || len(data) == 0 { + t.Fatalf("skill %s missing after seed: %v", name, err) + } + } + // 第二次种子:幂等,不再写入。 + if n, err := Seed(dir); err != nil || n != 0 { + t.Fatalf("second seed must be a no-op, got %d err=%v", n, err) + } + // 用户修订不被覆盖。 + custom := filepath.Join(dir, "docx-author", "SKILL.md") + if err := os.WriteFile(custom, []byte("用户改过的配方"), 0o644); err != nil { + t.Fatal(err) + } + if n, _ := Seed(dir); n != 0 { + t.Fatalf("user-edited skill must not be overwritten, wrote %d", n) + } + data, _ := os.ReadFile(custom) + if string(data) != "用户改过的配方" { + t.Fatal("user edit must survive seeding") + } +} + +func TestNames(t *testing.T) { + names := Names() + if len(names) != 3 || names[0] != "docx-author" || names[2] != "xlsx-author" { + t.Fatalf("want three sorted builtin skills, got %v", names) + } +} diff --git a/internal/squadadapter/adapter.go b/internal/squadadapter/adapter.go index ee1ca09..218c501 100644 --- a/internal/squadadapter/adapter.go +++ b/internal/squadadapter/adapter.go @@ -19,6 +19,7 @@ import ( "time" "agent-native/exec" + "agent-native/runtime/subagent" "agent-squad/squad" core "agent-system-core" ) @@ -260,8 +261,45 @@ func isHoldStatus(s string) bool { } // AgentNativeOptions 可选组合根注入:返回配置且启用时,为每次小队运行注册 -// "agent-native" 内核后端工厂(模型/搜索/执行/技能根由组合根从 settings 解析)。 -var AgentNativeOptions func() (squad.AgentNativeConfig, bool) +// "agent-native" 内核后端工厂(模型/搜索/执行/MCP/技能根由组合根从 settings +// 解析;Subagent 工厂经 rc 桥接产品 SpawnSubagent)。 +var AgentNativeOptions func(rc Ctx) (squad.AgentNativeConfig, bool) + +// SubagentFactory 把内核委派端口(delegate_task)桥接到产品 SpawnSubagent: +// 委派信息集(目标/输入/写范围/非目标/完成判据/交付物)拼装为任务书, +// 子运行进入任务树(用量与事件统一记账);深度天然为 1——子运行是小队外 +// 的普通代理执行,不再嵌套小队。 +func SubagentFactory(rc Ctx) subagent.Factory { + return func(ctx context.Context, req subagent.Request) (subagent.Result, error) { + var b strings.Builder + b.WriteString("委派子任务\n目标:" + req.Goal) + if req.Inputs != "" { + b.WriteString("\n输入:" + req.Inputs) + } + if req.WriteScope != "" { + b.WriteString("\n可写范围(不得越界):" + req.WriteScope) + } + if req.NonGoals != "" { + b.WriteString("\n非目标:" + req.NonGoals) + } + b.WriteString("\n完成判据:" + req.DoneCriteria) + if len(req.Deliverables) > 0 { + b.WriteString("\n交付物:" + strings.Join(req.Deliverables, ", ")) + } + payload, err := rc.SpawnSubagent("delegatee", b.String(), "内核 delegate_task 委派") + if err != nil { + return subagent.Result{}, err + } + res := subagent.Result{Text: firstNonEmpty(payload.Text, payload.Summary)} + switch payload.Status { + case core.RunCompleted: + res.Success = true + default: + res.Err = "子代理结束状态:" + string(payload.Status) + } + return res, nil + } +} // buildBackends 组装小队后端解析器:"agent-system"(SpawnSubagent 路径)恒注册; // 组合根注入 AgentNativeOptions 时额外注册 "agent-native" 内核工厂——角色显式 @@ -273,7 +311,7 @@ func buildBackends(rc Ctx) *squad.BackendResolver { return &stepBackend{rc: rc, role: role}, nil }) if AgentNativeOptions != nil { - if cfg, ok := AgentNativeOptions(); ok { + if cfg, ok := AgentNativeOptions(rc); ok { backends.RegisterAgentNativeFull(cfg) } } @@ -293,14 +331,23 @@ func builtinRoles() []squad.RoleSpec { } return fallback } + // 执行配置显式化(agent-native 后端消费;agent-system 路径忽略不生效但保持声明一致): + // 规划/调研/审校一律 BashPermission=deny(无命令执行需求); + // 撰稿员需要 run_python 生成文档——ask 档(每次运行首次使用前人工放行)。 + // 工具白名单按角色最小化;撰稿员技能指向启动种子的三套交付配方。 return []squad.RoleSpec{ - {Role: "planner", Name: "规划员", Backend: "agent-system", BashPermission: "deny", + {Role: "planner", Name: "规划员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny, + Tools: []string{"read_file", "list_dir", "grep"}, Keywords: []string{"梳理", "规划", "分析"}, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")}, - {Role: "researcher", Name: "调研员", Backend: "agent-system", BashPermission: "deny", + {Role: "researcher", Name: "调研员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny, + Tools: []string{"web_search", "tavily_search", "fetch_url", "read_file", "write_file"}, Keywords: []string{"调研", "检索", "资料"}, Instructions: instr("researcher", "围绕目标调研关键问题,输出简明结论与依据。")}, - {Role: "writer", Name: "撰稿员", Backend: "agent-system", BashPermission: "deny", + {Role: "writer", Name: "撰稿员", Backend: "agent-system", BashPermission: squad.BashPermissionAsk, + Tools: []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "load_skill", "run_python", "run_shell"}, + Skills: []string{"docx-author", "xlsx-author", "pptx-author"}, Keywords: []string{"撰写", "成文", "报告"}, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")}, - {Role: "reviewer", Name: "审校员", Backend: "agent-system", BashPermission: "deny", + {Role: "reviewer", Name: "审校员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny, + Tools: []string{"read_file", "grep", "glob", "list_dir"}, Keywords: []string{"审校", "把关", "终审"}, Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")}, } } diff --git a/internal/squadadapter/adapter_test.go b/internal/squadadapter/adapter_test.go index f2394c2..26f1d13 100644 --- a/internal/squadadapter/adapter_test.go +++ b/internal/squadadapter/adapter_test.go @@ -1,9 +1,14 @@ package squadadapter import ( + "context" + "errors" + "strings" "testing" + "agent-native/runtime/subagent" "agent-squad/squad" + core "agent-system-core" ) func TestMapPlanAndStatus(t *testing.T) { @@ -49,3 +54,112 @@ func TestIsHoldStatus(t *testing.T) { } } } + +// subagentFakeRc 只实现 SpawnSubagent 的最小 Ctx 桩(其余方法本测试不触达)。 +type subagentFakeRc struct { + gotName string + gotTask string + payload core.AgentResultPayload + err error +} + +func (f *subagentFakeRc) Context() context.Context { return context.Background() } +func (f *subagentFakeRc) Run() core.Run { return core.Run{} } +func (f *subagentFakeRc) Settings() core.EffectiveSettings { return nil } +func (f *subagentFakeRc) Emit(typ core.StreamEventType, payload any) error { return nil } +func (f *subagentFakeRc) AskHITL(card core.HITLCard) (core.HITLResponse, error) { + return core.HITLResponse{}, nil +} +func (f *subagentFakeRc) UpdatePlan(p core.PlanSnapshot) error { return nil } +func (f *subagentFakeRc) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { + return core.Artifact{}, nil +} +func (f *subagentFakeRc) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) { + f.gotName, f.gotTask = name, task + return f.payload, f.err +} + +func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) { + rc := &subagentFakeRc{payload: core.AgentResultPayload{Status: core.RunCompleted, Text: "子代理结论"}} + res, err := SubagentFactory(rc)(context.Background(), subagent.Request{ + Goal: "查证数据", Inputs: "docs/v2", WriteScope: "research/", + NonGoals: "不做实现", DoneCriteria: "三来源对照", + Deliverables: []string{"notes.md"}, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success || res.Text != "子代理结论" { + t.Fatalf("bridge result wrong: %+v", res) + } + for _, want := range []string{"查证数据", "docs/v2", "research/", "不做实现", "三来源对照", "notes.md"} { + if !strings.Contains(rc.gotTask, want) { + t.Fatalf("task brief missing %q:\n%s", want, rc.gotTask) + } + } + + // 未完成状态:Success=false 且 Err 带结束状态。 + rc2 := &subagentFakeRc{payload: core.AgentResultPayload{Status: core.RunFailed, Summary: "预算耗尽"}} + res2, err := SubagentFactory(rc2)(context.Background(), subagent.Request{Goal: "g", DoneCriteria: "d"}) + if err != nil { + t.Fatal(err) + } + if res2.Success || !strings.Contains(res2.Err, "failed") || res2.Text != "预算耗尽" { + t.Fatalf("failure mapping wrong: %+v", res2) + } + + // SpawnSubagent 报错透传。 + rc3 := &subagentFakeRc{err: errors.New("spawn refused")} + if _, err := SubagentFactory(rc3)(context.Background(), subagent.Request{Goal: "g", DoneCriteria: "d"}); err == nil { + t.Fatal("spawn error must propagate") + } +} + +func TestBuiltinRolesExplicitExecConfig(t *testing.T) { + roles := builtinRoles() + byKey := map[string]squad.RoleSpec{} + for _, r := range roles { + byKey[r.Role] = r + } + if len(roles) != 4 { + t.Fatalf("want 4 builtin roles, got %d", len(roles)) + } + // 全员 BashPermission 显式声明。 + for _, r := range roles { + switch r.BashPermission { + case squad.BashPermissionDeny, squad.BashPermissionAsk, squad.BashPermissionAllow: + default: + t.Fatalf("role %s bash_permission not explicit: %q", r.Role, r.BashPermission) + } + } + // 调研员:search + fetch;审校员:grep/glob;撰稿员:ask + 三套技能 + 生成工具。 + for _, want := range []string{"web_search", "fetch_url"} { + if !containsStr(byKey["researcher"].Tools, want) { + t.Fatalf("researcher tools missing %s: %v", want, byKey["researcher"].Tools) + } + } + for _, want := range []string{"grep", "glob"} { + if !containsStr(byKey["reviewer"].Tools, want) { + t.Fatalf("reviewer tools missing %s: %v", want, byKey["reviewer"].Tools) + } + } + w := byKey["writer"] + if w.BashPermission != squad.BashPermissionAsk { + t.Fatalf("writer needs ask (run_python for docs), got %q", w.BashPermission) + } + if !containsStr(w.Tools, "run_python") || !containsStr(w.Tools, "load_skill") { + t.Fatalf("writer tools missing generation path: %v", w.Tools) + } + if len(w.Skills) != 3 || !containsStr(w.Skills, "docx-author") || !containsStr(w.Skills, "pptx-author") { + t.Fatalf("writer skills wrong: %v", w.Skills) + } +} + +func containsStr(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +}