Merge pull request 'Complete the B10 assembly: MCP config, subagent bridge, explicit roles, and skill-borne recipes' (#36) from feature/full-agent-b10-r2 into main
This commit was merged in pull request #36.
This commit is contained in:
+58
-4
@@ -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, ",") {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 登记;修订生成新文件名。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user