Complete the B10 assembly: MCP config, subagent bridge, explicit roles, and skill-borne recipes

Why: the full-agent batch left three B10 sub-items open - the
composition root lacked MCP server injection and a Subagent factory,
builtin squad roles carried no explicit Tools/Skills/BashPermission
declarations, and the three Python deliverable recipes still lived
inline in the base.deliverables prompt instead of the skills system.

Evidence: go vet ./... clean; go test ./... 21 packages ok. New cases:
skills seeding idempotency with user-edit preservation and sorted
builtin names; the SubagentFactory bridge mapping delegation fields
into SpawnSubagent task briefs and status/errors back; builtin-role
assertions (all four explicit bash permissions, researcher
search+fetch, reviewer grep+glob, writer ask + 3 skills +
run_python/load_skill); MCP manager cache reuse/rebuild/disable
behavior including invalid JSON and config races.

Impact: internal/skills embeds docx/xlsx/pptx-author SKILL.md and
seeds <data>/skills at startup; base.deliverables references the
skills and keeps only import one-liners; squadadapter.AgentNativeOptions
now receives rc and gains SubagentFactory(rc); app.go adds
tools.mcp.servers loading with a config-keyed manager cache (old
manager closed on rebuild).

Refs: Dev task feature/full-agent-b10-r2 (verifier follow-ups).
This commit is contained in:
2026-10-01 06:19:32 +00:00
parent 87a3d0dbfb
commit 642fec6397
10 changed files with 532 additions and 29 deletions
+58 -4
View File
@@ -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)落 <data>/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, ",") {
+73
View File
@@ -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()
}
+6 -18
View File
@@ -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`。
要求与边界:
@@ -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 传相对路径);修订生成新文件名而不是覆盖。
@@ -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 登记;修订生成新文件名。
@@ -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 登记;修订生成新文件名。
+67
View File
@@ -0,0 +1,67 @@
// Package skills 内置技能库:go:embed 三套交付物生成配方(docx/xlsx/pptx),
// 应用启动时种子到 <data>/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
}
+44
View File
@@ -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)
}
}
+54 -7
View File
@@ -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", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
}
}
+114
View File
@@ -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
}