Add the deliverable quality system and a squad entry #39

Merged
weichen merged 2 commits from feature/deliverable-quality-system into main 2026-10-01 09:35:55 +00:00
43 changed files with 2292 additions and 36 deletions
+11
View File
@@ -0,0 +1,11 @@
# 交付物评测
仓内 `testdata/` 是脱敏短样例,用来确认评分卡能区分“结论先行”和“栏目式笔记”。
真实会议材料不要放进仓库。放到 `~/.agent-system/eval/`,或设置 `EVAL_DIR`。其中的 `.md` 会在 `go test ./eval/` 里被打分并打印,缺失该目录时测试跳过。
```
cd modules/backend && go test ./eval/ ./internal/quality/ -count=1
```
两种入口的切换由 `DecideSwitch` 判断:squad 分数不低于 direct,并且直通路径多出来的首字延迟 P50 不超过 3 秒。没有同一评测集上的实测分数时,结果是不切换,`runtime.entry` 默认保持 `direct`。
+14
View File
@@ -0,0 +1,14 @@
package eval
import "strings"
// MissingThemes 报告候选文本没有覆盖的参考主题。主题是短词,不是整句照抄。
func MissingThemes(candidate string, themes []string) []string {
var missing []string
for _, theme := range themes {
if !strings.Contains(candidate, theme) {
missing = append(missing, theme)
}
}
return missing
}
+62
View File
@@ -0,0 +1,62 @@
// Package eval 用固定样例比较交付质量。真实会议材料放在仓外
// ~/.agent-system/eval/,或用 EVAL_DIR 指向该目录;仓内只放脱敏短样例。
package eval
import (
"os"
"path/filepath"
"testing"
"agent-system-backend/internal/quality"
)
func TestDesensitizedSampleBeatsColumnNotes(t *testing.T) {
good := scoreFixture(t, "good.md")
bad := scoreFixture(t, "bad.md")
if !good.Passed {
t.Fatalf("good fixture should pass: %+v", good)
}
if bad.Score >= good.Score {
t.Fatalf("bad %d should be below good %d", bad.Score, good.Score)
}
missing := MissingThemes(readFixture(t, "good.md"), []string{"相关性", "标签", "未指明"})
if len(missing) > 0 {
t.Fatalf("good fixture missed themes %v", missing)
}
}
func TestExternalEvalDirOptional(t *testing.T) {
dir := os.Getenv("EVAL_DIR")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
t.Skip(err)
}
dir = filepath.Join(home, ".agent-system", "eval")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Skip("no external eval dir")
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
continue
}
report := quality.ScoreFile(filepath.Join(dir, entry.Name()))
t.Logf("%s score=%d passed=%v", entry.Name(), report.Score, report.Passed)
}
}
func scoreFixture(t *testing.T, name string) quality.Report {
t.Helper()
return quality.ScoreText(readFixture(t, name))
}
func readFixture(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return string(data)
}
+45
View File
@@ -0,0 +1,45 @@
package eval
import (
"fmt"
"time"
)
// EntryScore 是一种入口在同一评测集上的结果。
type EntryScore struct {
Name string
Score int
FirstTokenP50 time.Duration
}
// SwitchDecision 判断能不能把默认入口改成 squad。
// 两条都要满足:squad 分数不低于 direct,直通路径多出来的首字延迟 P50 不超过 3 秒。
type SwitchDecision struct {
Met bool
Reason string
}
func DecideSwitch(direct, squad *EntryScore) SwitchDecision {
if direct == nil || squad == nil {
return SwitchDecision{Met: false, Reason: "还没有两种入口在同一评测集上的实测分数,默认保持 direct"}
}
extra := squad.FirstTokenP50 - direct.FirstTokenP50
if extra < 0 {
extra = 0
}
if squad.Score < direct.Score {
return SwitchDecision{Met: false, Reason: fmt.Sprintf("squad 分数 %d 低于 direct %d", squad.Score, direct.Score)}
}
if extra > 3*time.Second {
return SwitchDecision{Met: false, Reason: fmt.Sprintf("直通路径额外首字延迟 %s,超过 3 秒", extra)}
}
return SwitchDecision{Met: true, Reason: fmt.Sprintf("squad %d 不低于 direct %d,额外首字延迟 %s", squad.Score, direct.Score, extra)}
}
// Report 写成可以附在切换 PR 上的短报告。未达标时明确保持 direct。
func Report(dec SwitchDecision) string {
if dec.Met {
return "切换条件已满足:" + dec.Reason + "。可以把 runtime.entry 的默认值改为 squad,并保留 direct 作为回退。合入前需要产品确认。"
}
return "切换条件未满足:" + dec.Reason + "。runtime.entry 默认值保持 direct。"
}
+27
View File
@@ -0,0 +1,27 @@
package eval
import (
"strings"
"testing"
"time"
)
func TestSwitchStaysDirectWithoutScores(t *testing.T) {
dec := DecideSwitch(nil, nil)
if dec.Met || !strings.Contains(Report(dec), "保持 direct") {
t.Fatalf("%+v %s", dec, Report(dec))
}
}
func TestSwitchRequiresScoreAndLatency(t *testing.T) {
direct := &EntryScore{Name: "direct", Score: 80, FirstTokenP50: time.Second}
slow := &EntryScore{Name: "squad", Score: 90, FirstTokenP50: 5 * time.Second}
if DecideSwitch(direct, slow).Met {
t.Fatal("extra latency above 3s must block the switch")
}
ok := &EntryScore{Name: "squad", Score: 80, FirstTokenP50: 3 * time.Second}
dec := DecideSwitch(direct, ok)
if !dec.Met || !strings.Contains(Report(dec), "改为 squad") {
t.Fatalf("%+v %s", dec, Report(dec))
}
}
+3
View File
@@ -0,0 +1,3 @@
# 讨论过程
今天围绕文档上传聊了一会儿。大家说了很多,也有一些不同看法。后续再看吧。
+16
View File
@@ -0,0 +1,16 @@
# 上传文档应先判断相关性,再打标签入库
## 先校验,再生成
说话人1指出,上传后的第一步是判断文档与项目是否相关。确认后再拆开打标签。
一句话:没有这层记忆,后面的摘要和追溯都没有依据。
## 不要在上传时提示缺什么
依据会议原话,缺项提示放到体系成型之后。
一句话:上传节点是收集,不是验收。
## 基线要能迭代
新内容和已有基线比对,冲突时先问人。
一句话:一份基线反复补,而不是每次上传都新开一篇。
## 下一步
事项是写出第一版需求。负责人是陈威。期限是周五前。说不清的一律写未指明。
+1
View File
@@ -168,6 +168,7 @@ func Run(opt Options) error {
subRunner := &runtime.SubLLMRunner{Prompts: promptSvc}
mockRunner := &runtime.MockRunner{}
mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
mgr.SetSquadRunner(&runtime.SquadRunner{})
// 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。
gate := intentgate.New()
@@ -0,0 +1,12 @@
---
id: agent.squad.analyst
layer: agent
version: 1
title: 小队·分析员
description: 把材料写成洞察报告
---
你是小队的分析员。把同一份材料写成洞察,而不是会议纪要。
- 开头用一句话写出判断。
- 正文按现象、影响、隐性顾虑、建议四层展开。每层都要引用材料里的原话作为依据。
- 结尾写下一步行动:负责人、时间和完成标志。材料没写的写成「未指明」。
@@ -0,0 +1,12 @@
---
id: agent.squad.assistant
layer: agent
version: 1
title: 小队·助手
description: 简单任务的单步完成者
---
你是小队的助手,负责把一件已经分诊为简单的事一次做完。
- 结论放在开头。需要成文时按交付物工作流生成文件,lint 无 error 后再 register_artifact。
- 不要在回复里声称文件已经生成,除非这一轮真的登记了工件。
- 问答和改写直接给结果,不要拆成多角色流程。
+12 -1
View File
@@ -1,7 +1,7 @@
---
id: base.deliverables
layer: base
version: 3
version: 4
title: 交付物工作流
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
---
@@ -18,6 +18,17 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…)。
交付物族(所有任务共用,不限于会议):
- 会议:主视图是纪要摘要(method.distill)。还可生成智能笔记(method.timeline)和洞察报告(method.insight)。
- 报告:主视图是完整版。还可生成执行摘要和一页纸。
- 方案:主视图是方案正文。还可生成评审要点。
- 对比:主视图是对比矩阵加分场景结论。还可生成一页纸决策说明。
默认只生成主视图。登记之后,如果有 submit_card,用它交一张标题为「还可以生成」的卡片,正文每行列出一个其余视图、它回答的问题,以及用户怎样一句话让你再生成。没有 submit_card 时,把同一份清单写在回复末尾。用户点名某一视图再生成那一篇,不要一次把整族都写完。
长文档(一级标题达到四节)在正文里放至少一个视觉摘要:状态卡、卡片网格、三栏要求或流程图。用 housestyle.visuals,不要用文字方框假装图形。生成后用 quality_review 自检,不通过就按意见改一版再登记。
技能与路径:
- 技能目录是环境变量 `AGENT_SKILLS_DIR`(环境段会写出绝对路径)。`PYTHONPATH` 已包含其中的 `house-style`,脚本里直接 `import housestyle`。
+5 -3
View File
@@ -1,7 +1,7 @@
---
id: base.quality
layer: base
version: 1
version: 2
title: 专业交付标准
description: 对回复、文档、表格、演示、代码与数据一律适用的结构、语言、数据与版式标准(常驻基座)
---
@@ -9,9 +9,11 @@ description: 对回复、文档、表格、演示、代码与数据一律适用
结构:
- 结论先行:先给结论、建议或交付物,再给依据。按金字塔组织,只看开头也能决策。
- 同一层级互斥且穷尽:要点不重叠、不遗漏。标题写成判断或主题,不用“概述”“其他”充数。
- 结论先行:先给结论、建议或交付物,再给依据。标题写成判断,读者只看标题也能知道这一节主张什么。
- 每一组要点后面用一句话收束:这句话要能独立成立,不要复述要点原文。
- 同一层级互斥且穷尽:要点不重叠、不遗漏。不用“概述”“其他”“讨论过程”“补充说明”这种栏目名当标题。
- 层级不超过三级。每一节都有实质内容;没有内容就不要开这一节。
- 行动项写清事项、负责人和时间。原文没有的写“未指明”,不编造人名或日期。
- 先规划结构再落笔。修改已有内容时先通读再改,范围与请求一致。
语言:
@@ -0,0 +1,15 @@
---
id: method.distill
layer: aux
version: 1
title: 提炼成主题
description: 把材料收成互斥的主题,主题名就是结论,每组要点后有一句话总结
---
按提炼法组织内容,适用于纪要摘要、报告正文、方案要点,不限于某一种文档。
- 先通读全部材料,再按读者要做的决定分组。不要按发言顺序或原文小标题照抄。
- 每个主题的标题写成一句判断。禁止用“讨论过程”“概述”“其他”“补充”当标题。
- 同一层主题互斥且穷尽:一条信息只进一个主题;材料里影响决定的内容都要有去处。
- 每个主题先列要点,再写一行“一句话:……”。这一行必须能脱离要点单独成立。
- 要点里的判断要能回到材料:写明说话人、章节或文件名。材料没说的标“未指明”。
- 行动放在最后一节,用表格:事项、负责人、期限。缺了就写“未指明”。
@@ -0,0 +1,15 @@
---
id: method.insight
layer: aux
version: 1
title: 洞察四层
description: 用一句话洞察开头,每个发现按现象、影响、隐性顾虑、建议展开
---
按洞察法组织内容。它回答“所以呢”,不是复述发生了什么。会议、报告、方案、复盘都可以用。
- 开头先写“一句话洞察”:一句就能让决策者知道症结和建议方向。不要用背景铺垫开头。
- 每个发现用四层,缺一不可:现象(材料里实际说了什么)、影响(若不处理会怎样)、隐性顾虑(团队可能没说出口的假设)、建议(下一步具体做什么)。
- 发现的标题写成判断,可以带一个语义符号帮助扫读,但正文仍然是书面语。
- 建议必须可执行:谁、做什么、做到什么程度算完成。材料没指定负责人时写“未指明”,并说明建议由谁确认。
- 结尾单列“下一步行动”,每条一个动作,不把四层分析再抄一遍。
- 不把转录里的口误、重复和跑题写进洞察。和决定无关的细节留给时间线视图。
@@ -0,0 +1,15 @@
---
id: method.timeline
layer: aux
version: 1
title: 时间线保留原意
description: 按时间顺序保留发言人和原意,便于回查,不把讨论改写成结论
---
按时间线组织内容。它负责可回查,不负责下结论。会议记录、访谈、评审过程都适用。
- 用材料里的时间戳或顺序分节。没有时间戳时按材料顺序,并注明“原文无时间戳”。
- 保留发言人。原文是“说话人1”就保留,不猜测真名;文末说明称谓来源。
- 每条尽量用原意,可以删口头禅和重复,不可以改成你的结论或纠正说话人。
- 同一时段里换了议题就新开一节,节标题写这一段实际在谈的主题,不用“讨论过程”。
- 数字、专名、否定(“不要”“不是”)必须和原文一致。听不清或互相矛盾的地方照录,并标“原文如此”。
- 不在这一视图里写建议或决议。需要判断时改用提炼法或洞察法,另成一篇。
+2 -2
View File
@@ -1,7 +1,7 @@
---
id: task.compare
layer: task
version: 2
version: 3
title: 对比分析
description: 对多个对象(产品/方案/数据集)做多维对比并给结论
priority: 55
@@ -17,7 +17,7 @@ triggers:
- kind: command
command: /compare
---
用户要求对比分析。执行要求:
用户要求对比分析。主视图按 method.distill 写成“该选什么”的判断,不要写成功能清单。执行要求:
- 对比对象与维度先对齐:用户指定的按用户;未指定的按任务本质选影响决策的维度,说明选择理由。
- 数据可比性优先:同一维度下口径必须一致(时间范围、统计方式);口径不一致时先归一或标注不可比。
+3 -3
View File
@@ -1,7 +1,7 @@
---
id: task.doc-minutes
layer: task
version: 2
version: 3
title: 会议纪要生成
description: 依据上传的会议记录/录音稿生成结构完整的会议纪要
priority: 70
@@ -19,11 +19,11 @@ triggers:
- kind: command
command: /minutes
---
用户要求依据上传文档生成会议纪要。严格按以下要求执行:
用户要求依据上传文档生成会议纪要。主视图按 method.distill 写纪要摘要。严格按以下要求执行:
- 以文档全文为依据,通读后再动笔;覆盖全部议题,不遗漏要点,不臆造文档之外的内容;无法确认的信息标注"文档未明确"。
- 按输出契约的结构组织;人名、时间、数字必须与原文一致,不确定的保留原文写法。
- 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。
- 区分三件事:谁说了什么、集体结论、待办事项。不要把发言当决议,也不要开一节叫“讨论过程”。谁说了什么放在要点的出处里;需要逐句回查时,另生成智能笔记(method.timeline),不要塞进摘要。
- 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。
- 待办事项若原文未指明负责人或期限,写"未指明",不要编造。
- 版面:标题下用键值表写会议信息(时间、地点、主持人、参会人、缺席)。讨论按议题分节,每节含要点、分歧和结论。决议用真编号列表,不要再手写“1.”。待办用表格,列:序号、事项、负责人、期限、状态;原文没有的写“未指明”。发言人用统一称谓(职务或姓名);原文只有“说话人X”时保留并在文末说明。引语只留关键句,并标注是谁说的。
+25
View File
@@ -0,0 +1,25 @@
---
id: task.insight
layer: task
version: 1
title: 洞察报告
description: 从材料里抽出决策者需要知道的判断,而不是复述过程
priority: 72
requiresAttachments: true
outputContract: |
1. 一句话洞察
2. 若干发现:现象、影响、隐性顾虑、建议
3. 下一步行动
triggers:
- kind: intent
keywords: 洞察, 洞察报告
- kind: command
command: /insight
---
用户要的是判断,不是会议记录。按 method.insight 写洞察报告。
- 开头就是一句话洞察。背景最多一句,而且要服务于这句判断。
- 每个发现四层都要有。建议写到可以交给人去做的程度。
- 标题是结论。不要用“讨论要点”“过程回顾”。
- 材料里的口误和跑题不进入正文。需要保留原话时,另做时间线(method.timeline)。
- 交付走交付物工作流。一级标题达到四节时放一个视觉摘要。quality_review 通过后再登记。回复只复述一句话洞察和所用样式。
+2 -2
View File
@@ -1,7 +1,7 @@
---
id: task.report
layer: task
version: 2
version: 3
title: 报告撰写
description: 把材料组织成正式报告/公文(含执行摘要与附录建议)
priority: 55
@@ -17,7 +17,7 @@ triggers:
- kind: command
command: /report
---
用户要求撰写正式报告。执行要求:
用户要求撰写正式报告。主视图是完整版,正文按 method.distill 组织;若用户要的是判断和风险而不是叙述,改用 method.insight。执行要求:
- 先明确报告的受众与用途(对上汇报/对外发布/内部存档),文风与详略随之调整;未说明时按内部正式报告处理并标注假设。
- 事实与观点分离:分析章节可以论证,事实陈述要有出处(文档段落/数据表)。
@@ -0,0 +1,8 @@
---
id: tool.quality_review
layer: tool
version: 1
title: quality_review 工具描述
description: 交付物评分卡。登记前调用,不通过就按意见改
---
给工作区内的交付物打分。path 用相对路径。返回 JSON:passed、score、六个维度(含引用原文的 evidence)和 violations。passed 为 false 时先改再登记,不要带着失败结果声称完成。
+22 -1
View File
@@ -46,7 +46,8 @@ func TestDefaultLibraryParses(t *testing.T) {
}
// 关键模块在位:基座五块 + 环境 + 纪要技能 + 通用子代理。
for _, id := range []string{"base.identity", "base.behavior", "base.caution", "base.tools", "base.style", "base.quality",
"env.runtime", "task.doc-minutes", "agent.sub.general", "aux.session-compact", "tool.tavily_search", "tool.web_search"} {
"env.runtime", "task.doc-minutes", "task.insight", "method.distill", "method.insight", "method.timeline",
"agent.sub.general", "agent.squad.assistant", "agent.squad.analyst", "aux.session-compact", "tool.tavily_search", "tool.web_search"} {
if !seen[id] {
t.Errorf("required module missing: %s", id)
}
@@ -153,6 +154,26 @@ func TestResolveSystemSkillTriggers(t *testing.T) {
}
}
func TestTaskInjectsNamedMethods(t *testing.T) {
svc := mustService(t)
_, hits, err := svc.ResolveSystem(core.AgentProfile{}, TriggerCtx{Text: "根据文档生成会议纪要", HasAttachments: true}, EnvData{}, "")
if err != nil {
t.Fatal(err)
}
injected := map[string]bool{}
for _, h := range hits {
if h.Injected {
injected[h.ModuleID] = true
}
}
if !injected["method.distill"] || !injected["method.timeline"] {
t.Fatalf("minutes should pull its methods, got %+v", hits)
}
if injected["method.insight"] {
t.Fatal("minutes must not pull the insight method")
}
}
// 同轮多技能命中:只注入优先级最高者,其余记录让位(可观测)。
func TestResolveSystemSkillPriority(t *testing.T) {
svc := mustService(t)
+21
View File
@@ -4,6 +4,7 @@ import (
"embed"
"fmt"
"io/fs"
"regexp"
"sort"
"strings"
@@ -265,6 +266,7 @@ func (s *Service) ResolveSystem(prof core.AgentProfile, tc TriggerCtx, env EnvDa
for i, c := range matched {
if i == 0 {
addBlock(c.m, c.reason, true)
injectMethods(c.m.Body, byID, addBlock)
continue
}
addBlock(c.m, "superseded-by:"+matched[0].m.ID, false)
@@ -272,6 +274,25 @@ func (s *Service) ResolveSystem(prof core.AgentProfile, tc TriggerCtx, env EnvDa
return blocks, hits, nil
}
var methodRef = regexp.MustCompile(`method\.[a-z0-9-]+`)
// injectMethods 把任务正文点名的方法模块一并注入。方法是 aux 层,
// 不参与 task 优先级竞争,所以纪要可以同时带上提炼和时间线,而不会挤掉任务本身。
func injectMethods(body string, byID map[string]core.PromptModule, add func(core.PromptModule, string, bool)) {
seen := map[string]bool{}
for _, id := range methodRef.FindAllString(body, -1) {
if seen[id] {
continue
}
seen[id] = true
m, ok := byID[id]
if !ok || m.Disabled || m.Layer != core.LayerAux {
continue
}
add(m, "method-of-task", true)
}
}
// priority 缺省 50;大者优先。
func priority(m core.PromptModule) int {
if m.Priority == 0 {
+32
View File
@@ -0,0 +1,32 @@
package quality
import (
"os"
"path/filepath"
"strings"
"time"
"agent-squad/squad"
)
// Checker 把评分卡接到小队质检。不通过时把意见写到工作区 quality-feedback.md,
// 供返修步骤读入。
type Checker struct{}
func (Checker) Check(req squad.CheckRequest) *squad.QualityReport {
path := filepath.Join(req.Workspace, req.Deliverable)
scored := ScoreFile(path)
report := &squad.QualityReport{
CheckedAt: time.Now(),
Score: float64(scored.Score),
Passed: scored.Passed,
Violations: scored.Violations,
}
if !scored.Passed {
body := "质检未通过,请按下列意见改一版,不要解释规则本身。\n\n"
body += strings.Join(scored.Violations, "\n")
body += "\n"
_ = os.WriteFile(filepath.Join(req.Workspace, "quality-feedback.md"), []byte(body), 0o644)
}
return report
}
+288
View File
@@ -0,0 +1,288 @@
// Package quality 给交付物打分。直通路径的 quality_review 和小队 Checker 用同一套规则。
// 默认是可复现的启发式评分;调用方可以注入 Judge,用轻量模型覆盖启发式结果。
package quality
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"unicode/utf8"
)
// Dimension 是评分卡上的一维。Score 为 0–100。Evidence 必须引用被评文本里的原句。
type Dimension struct {
ID string `json:"id"`
Score int `json:"score"`
Note string `json:"note"`
Evidence string `json:"evidence"`
}
// Report 是一次评分。Passed 要求总分不低于 70,且没有任何一维低于 40。
type Report struct {
Passed bool `json:"passed"`
Score int `json:"score"`
Dimensions []Dimension `json:"dimensions"`
Violations []string `json:"violations,omitempty"`
Lint []LintIssue `json:"lint,omitempty"`
}
// LintIssue 是 housestyle.lint 的一条结果。lint 不存在时留空,不因此判失败。
type LintIssue struct {
Severity string `json:"severity"`
Rule string `json:"rule"`
Message string `json:"message"`
}
// Judge 用模型给同一份正文打分。返回错误时调用方回落启发式。
type Judge func(ctx context.Context, text string) (Report, error)
// ScoreText 按六个维度给纯文本打分。
func ScoreText(text string) Report {
text = strings.TrimSpace(text)
dims := []Dimension{
scoreLead(text),
scoreStructure(text),
scoreComplete(text),
scoreEvidence(text),
scoreActionable(text),
scoreReadable(text),
}
total := 0
low := false
var violations []string
for _, d := range dims {
total += d.Score
if d.Score < 40 {
low = true
violations = append(violations, d.ID+":"+d.Note)
}
}
score := 0
if len(dims) > 0 {
score = total / len(dims)
}
if score < 70 {
violations = append(violations, fmt.Sprintf("总分 %d,低于 70", score))
}
return Report{
Passed: score >= 70 && !low,
Score: score,
Dimensions: dims,
Violations: violations,
}
}
// ScoreFile 读取文件正文再打分。docx 取段落文本;其他文件按 UTF-8 文本读。
// 能跑 housestyle.lint 时,error 级问题会使 Passed 变为 false。
func ScoreFile(path string) Report {
text, err := readDeliverable(path)
if err != nil {
return Report{Passed: false, Violations: []string{"无法读取交付物:" + err.Error()}}
}
report := ScoreText(text)
issues := lintFile(path)
report.Lint = issues
for _, issue := range issues {
if issue.Severity == "error" {
report.Passed = false
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
}
}
return report
}
func scoreLead(text string) Dimension {
title := firstHeading(text)
if title == "" {
return Dimension{ID: "结论先行", Score: 30, Note: "开头没有标题或判断", Evidence: snippet(text, 40)}
}
if bannedTitle(title) {
return Dimension{ID: "结论先行", Score: 20, Note: "标题是栏目名,不是判断", Evidence: title}
}
if containsAny(title, "应", "先", "不要", "建议", "必须", "改为", "停止") || utf8.RuneCountInString(title) >= 8 {
return Dimension{ID: "结论先行", Score: 90, Note: "标题本身是判断", Evidence: title}
}
return Dimension{ID: "结论先行", Score: 55, Note: "有标题,但还不是一句判断", Evidence: title}
}
func scoreStructure(text string) Dimension {
if strings.Contains(text, "一句话") {
return Dimension{ID: "结构", Score: 90, Note: "要点组有收束句", Evidence: evidenceLine(text, "一句话")}
}
if strings.Count(text, "#") >= 2 || strings.Count(text, "\n") >= 4 {
return Dimension{ID: "结构", Score: 60, Note: "有分层,但没有一句话收束", Evidence: snippet(text, 40)}
}
return Dimension{ID: "结构", Score: 30, Note: "几乎没有结构", Evidence: snippet(text, 40)}
}
func scoreComplete(text string) Dimension {
n := utf8.RuneCountInString(text)
switch {
case n >= 180:
return Dimension{ID: "完整性", Score: 85, Note: "篇幅足以覆盖一个决定", Evidence: fmt.Sprintf("%d 字", n)}
case n >= 80:
return Dimension{ID: "完整性", Score: 60, Note: "偏短,可能漏了材料里的决定", Evidence: fmt.Sprintf("%d 字", n)}
default:
return Dimension{ID: "完整性", Score: 25, Note: "内容过短", Evidence: fmt.Sprintf("%d 字", n)}
}
}
func scoreEvidence(text string) Dimension {
for _, word := range []string{"说话人", "依据", "出处", "原文", "文件"} {
if line := evidenceLine(text, word); line != "" {
return Dimension{ID: "证据", Score: 85, Note: "判断能回到材料", Evidence: line}
}
}
return Dimension{ID: "证据", Score: 30, Note: "没有出处", Evidence: snippet(text, 40)}
}
func scoreActionable(text string) Dimension {
for _, word := range []string{"未指明", "负责人", "期限", "之前完成", "周五"} {
if line := evidenceLine(text, word); line != "" {
return Dimension{ID: "可执行性", Score: 85, Note: "行动写了人或时间", Evidence: line}
}
}
return Dimension{ID: "可执行性", Score: 35, Note: "没有可交给人的行动", Evidence: snippet(text, 40)}
}
func scoreReadable(text string) Dimension {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(strings.TrimLeft(line, "#"))
if bannedTitle(line) && utf8.RuneCountInString(line) <= 12 {
return Dimension{ID: "可读性", Score: 25, Note: "使用了栏目式标题", Evidence: line}
}
}
if strings.Contains(text, "。。") {
return Dimension{ID: "可读性", Score: 40, Note: "有连续句号", Evidence: "。。"}
}
return Dimension{ID: "可读性", Score: 85, Note: "标题可扫读", Evidence: firstHeading(text)}
}
func firstHeading(text string) string {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(strings.TrimLeft(line, "#"))
line = strings.TrimSpace(line)
if line != "" {
return line
}
}
return ""
}
func bannedTitle(title string) bool {
for _, word := range []string{"讨论过程", "概述", "补充说明", "其他"} {
if strings.Contains(title, word) {
return true
}
}
return false
}
func containsAny(s string, words ...string) bool {
for _, word := range words {
if strings.Contains(s, word) {
return true
}
}
return false
}
func evidenceLine(text, word string) string {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if strings.Contains(line, word) {
return snippet(line, 80)
}
}
return ""
}
func snippet(text string, n int) string {
text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
if utf8.RuneCountInString(text) <= n {
return text
}
runes := []rune(text)
return string(runes[:n]) + "…"
}
func readDeliverable(path string) (string, error) {
if strings.EqualFold(filepath.Ext(path), ".docx") {
return readDocxText(path)
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
if !utf8.Valid(data) {
return "", fmt.Errorf("不是文本文件")
}
return string(data), nil
}
func readDocxText(path string) (string, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return "", err
}
defer zr.Close()
var doc *zip.File
for _, f := range zr.File {
if f.Name == "word/document.xml" {
doc = f
break
}
}
if doc == nil {
return "", fmt.Errorf("docx 没有 document.xml")
}
rc, err := doc.Open()
if err != nil {
return "", err
}
defer rc.Close()
decoder := xml.NewDecoder(rc)
var b strings.Builder
for {
tok, err := decoder.Token()
if err != nil {
break
}
switch el := tok.(type) {
case xml.StartElement:
if el.Name.Local == "p" && b.Len() > 0 {
b.WriteByte('\n')
}
case xml.CharData:
b.Write(bytes.TrimSpace(el))
}
}
return b.String(), nil
}
var lintJSON = regexp.MustCompile(`(?s)\[.*\]`)
func lintFile(path string) []LintIssue {
cmd := exec.Command("python3", "-m", "housestyle.lint", path)
out, err := cmd.CombinedOutput()
if err != nil && !bytes.Contains(out, []byte("\"rule\"")) {
return nil
}
raw := out
if loc := lintJSON.Find(out); loc != nil {
raw = loc
}
var issues []LintIssue
if json.Unmarshal(raw, &issues) != nil {
return nil
}
return issues
}
+29
View File
@@ -0,0 +1,29 @@
package quality
import "testing"
func TestScoreTextPrefersJudgementOverColumnHeading(t *testing.T) {
good := `# 上传文档应先判断相关性再入库
## 先校验,再生成
- 说话人1:上传后第一步是判断是否与项目相关。
一句话:没有记忆库,后面的生成和追溯都没有依据。
## 下一步
- 事项:写出第一版需求。负责人:陈威。期限:周五前;说不清的写未指明。
`
bad := "# 讨论过程\n\n今天大家聊了很多,感觉还要再看看。\n"
gotGood := ScoreText(good)
gotBad := ScoreText(bad)
if !gotGood.Passed || gotGood.Score < 70 {
t.Fatalf("good text should pass: %+v", gotGood)
}
if gotBad.Passed || gotBad.Score >= gotGood.Score {
t.Fatalf("column heading should score lower: bad=%+v good=%d", gotBad, gotGood.Score)
}
for _, d := range gotGood.Dimensions {
if d.Evidence == "" {
t.Errorf("%s missing evidence", d.ID)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
package runmgr
import (
"testing"
"agent-system-backend/internal/runtime"
core "agent-system-core"
)
type nameRunner struct{ name string }
func (n nameRunner) Name() string { return n.name }
func (n nameRunner) Execute(rc runtime.RunCtx) error { return nil }
func TestSelectConversationRunner(t *testing.T) {
m := &Manager{runner: nameRunner{name: "direct"}, mock: nameRunner{name: "mock"}, squad: nameRunner{name: "squad"}}
if got := selectConversationRunner(m, core.EffectiveSettings{}); got.Name() != "direct" {
t.Fatalf("default = %s", got.Name())
}
squadEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad"}}
if got := selectConversationRunner(m, squadEff); got.Name() != "squad" {
t.Fatalf("squad entry = %s", got.Name())
}
mockEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad", "mock": true}}
if got := selectConversationRunner(m, mockEff); got.Name() != "mock" {
t.Fatalf("mock wins = %s", got.Name())
}
}
+48 -12
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
@@ -32,6 +33,7 @@ type Manager struct {
subRunner runtime.Runner // subagent runner(LLM 轻任务)
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
mockSub runtime.Runner // 子代理脚本回放
squad runtime.Runner // runtime.entry=squad 时的对话入口
gate IntentGate
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
@@ -52,13 +54,15 @@ type Manager struct {
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
type runHandle struct {
cancel context.CancelFunc
hitlCh chan hitlAnswer
assistant []core.Part
ctxObs *core.ContextObservation
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
started time.Time
cancel context.CancelFunc
hitlCh chan hitlAnswer
assistant []core.Part
ctxObs *core.ContextObservation
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
started time.Time
promptTokens int
completionTokens int
}
type hitlAnswer struct {
@@ -136,7 +140,23 @@ func (c *runCtx) History() []core.Message {
}
func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
func (c *runCtx) WorkspaceInputs() []string { return c.inputPaths }
func (c *runCtx) Subtask() string { return c.subTask }
// ConversationSandbox 是会话工作区根。宿主工具把内核步骤里生成的文件复制到这里再登记。
func (c *runCtx) ConversationSandbox() string {
if c.mgr.workspaceRoot == "" || c.run.Owner == "" || c.run.ConversationID == "" {
return ""
}
return filepath.Join(c.mgr.workspaceRoot, c.run.Owner, c.run.ConversationID)
}
// AddUsage 累加本 run 的模型用量,finishRun 写入 run.Usage。
func (c *runCtx) AddUsage(u core.Usage) {
c.mgr.mu.Lock()
c.h.promptTokens += u.PromptTokens
c.h.completionTokens += u.CompletionTokens
c.mgr.mu.Unlock()
}
func (c *runCtx) Subtask() string { return c.subTask }
// Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤
// (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。
@@ -566,6 +586,20 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]
// ---- run 生命周期 ----
// SetSquadRunner 注入 runtime.entry=squad 时使用的对话入口。
func (m *Manager) SetSquadRunner(r runtime.Runner) { m.squad = r }
// selectConversationRunner 按设置选择入口。mock 优先;squad 仅在注入了入口时生效。
func selectConversationRunner(m *Manager, eff core.EffectiveSettings) runtime.Runner {
if eff.GetBool("runtime.mock", false) {
return m.mock
}
if eff.GetString("runtime.entry", "direct") == "squad" && m.squad != nil {
return m.squad
}
return m.runner
}
// StartConversationRun 为一条已落库的用户消息启动 conversation run。
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
@@ -595,10 +629,7 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
m.active[run.ID] = h
m.mu.Unlock()
runner := m.runner
if eff.GetBool("runtime.mock", false) {
runner = m.mock
}
runner := selectConversationRunner(m, eff)
inputPaths, _ := m.stageInputs(conv, eff, inputs)
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths}
go m.execute(rc, runner)
@@ -645,6 +676,11 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
}
func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) {
m.mu.Lock()
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
m.mu.Unlock()
now := time.Now().UTC()
rc.run.EndedAt = &now
rc.run.Status = status
+185
View File
@@ -0,0 +1,185 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"agent-native/adapters/native"
"agent-native/runtime/tools"
core "agent-system-core"
)
// kernelToolNames 是内核工作区自带的工具。同名宿主工具不注入,避免步骤因冲突失败。
var kernelToolNames = map[string]bool{
"read_file": true, "write_file": true, "edit_file": true, "multi_edit": true,
"list_dir": true, "grep": true, "glob": true, "load_skill": true,
"run_python": true, "run_command": true, "web_search": true, "tavily_search": true,
"fetch_url": true, "delegate_task": true,
}
type producedSink interface {
add(ref *core.ArtifactRef)
}
type artifactList struct {
items []*core.ArtifactRef
}
func (a *artifactList) add(ref *core.ArtifactRef) {
if ref != nil {
a.items = append(a.items, ref)
}
}
// bridgeHostTools 把画像允许的产品工具包成内核宿主工具。
// register_artifact 会先把内核步骤目录里的文件复制到会话工作区。
func bridgeHostTools(rc RunCtx, produced *artifactList) []tools.Tool {
if produced == nil {
produced = &artifactList{}
}
policy := rc.Profile().Tools
var out []tools.Tool
for _, desc := range rc.Tools() {
if desc.Hidden || kernelToolNames[desc.Name] || !policy.ToolAllowed(desc.Name) {
continue
}
out = append(out, &bridgedTool{rc: rc, desc: desc, produced: produced})
}
if rc.Settings().GetBool("ui.cardflow", false) {
for _, desc := range cardToolDescs() {
if policy.ToolAllowed(desc.Name) {
out = append(out, &bridgedTool{rc: rc, desc: desc, produced: produced, card: true})
}
}
}
return out
}
type bridgedTool struct {
rc RunCtx
desc core.ToolDesc
produced *artifactList
card bool
}
func (b *bridgedTool) Name() string { return b.desc.Name }
func (b *bridgedTool) Description() string { return b.desc.Description }
func (b *bridgedTool) Parameters() json.RawMessage { return b.desc.Parameters }
func (b *bridgedTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
if b.desc.Name == "register_artifact" {
stageStepFile(ctx, conversationSandbox(b.rc), args)
}
if b.card {
out, err := executeCardTool(b.rc, b.desc.Name, args)
if err != nil {
return tools.Result{Content: err.Error(), IsError: true}, nil
}
return tools.Result{Content: out}, nil
}
res := b.rc.ExecuteTool(core.ToolCallRequest{Name: b.desc.Name, Arguments: args})
if res.Artifact != nil {
b.produced.add(&core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind})
}
if res.Status != "ok" {
return tools.Result{Content: res.Output, IsError: true}, nil
}
return tools.Result{Content: res.Output}, nil
}
func conversationSandbox(rc RunCtx) string {
s, ok := rc.(interface{ ConversationSandbox() string })
if !ok {
return ""
}
return s.ConversationSandbox()
}
func stageStepFile(ctx context.Context, sandbox string, args json.RawMessage) {
step := native.WorkDir(ctx)
if step == "" || sandbox == "" {
return
}
var a struct {
Path string `json:"path"`
}
if json.Unmarshal(args, &a) != nil || strings.TrimSpace(a.Path) == "" || strings.Contains(a.Path, "..") {
return
}
src := filepath.Join(step, filepath.FromSlash(a.Path))
dst := filepath.Join(sandbox, filepath.FromSlash(a.Path))
if _, err := os.Stat(dst); err == nil {
return
}
body, err := os.ReadFile(src)
if err != nil {
return
}
if os.MkdirAll(filepath.Dir(dst), 0o755) != nil {
return
}
_ = os.WriteFile(dst, body, 0o644)
}
func cardToolDescs() []core.ToolDesc {
return []core.ToolDesc{
{Name: toolCard, Description: "提交产物卡片", Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"summary":{"type":"string"},"body":{"type":"string"},"scope":{"type":"string"}},"required":["title","body"]}`)},
{Name: toolCardBegin, Description: "开始流式产物卡", Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"summary":{"type":"string"},"scope":{"type":"string"}},"required":["title"]}`)},
{Name: toolCardAppend, Description: "追加流式产物卡", Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`)},
{Name: toolCardCommit, Description: "收尾流式产物卡", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
}
}
func executeCardTool(rc RunCtx, name string, args json.RawMessage) (string, error) {
switch name {
case toolCard:
var a struct{ Title, Summary, Body, Scope string }
if err := json.Unmarshal(args, &a); err != nil || strings.TrimSpace(a.Title) == "" || strings.TrimSpace(a.Body) == "" {
return "", fmt.Errorf("submit_card 参数错误")
}
card := core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardDone, Tone: "ok", Body: []core.UINode{{Type: "markdown", Markdown: a.Body}}}
if a.Scope == "project" {
card.ProjectID = core.ID(rc.Run().ProjectID)
}
created, err := rc.SubmitCard(card)
if err != nil {
return "", err
}
return "已作为卡片发出(" + created.Title + ")", nil
case toolCardBegin:
var a struct{ Title, Summary, Scope string }
if err := json.Unmarshal(args, &a); err != nil || strings.TrimSpace(a.Title) == "" {
return "", fmt.Errorf("card_begin 参数错误")
}
card := core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardActive, Tone: "ok"}
if a.Scope == "project" {
card.ProjectID = core.ID(rc.Run().ProjectID)
}
created, err := rc.BeginStreamCard(card)
if err != nil {
return "", err
}
return "流式卡已开始(" + created.Title + ")", nil
case toolCardAppend:
var a struct{ Text string }
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
if err := rc.AppendStreamCard(a.Text); err != nil {
return "", err
}
return "已追加", nil
case toolCardCommit:
card, err := rc.CommitStreamCard()
if err != nil {
return "", err
}
return "已作为卡片发出(" + card.Title + ")", nil
default:
return "", nil
}
}
+22 -5
View File
@@ -231,12 +231,16 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
}
// 声明工具:编排型内置 + 注册表中画像允许的工具;描述文案库覆盖优先。
decls := []agentllm.ToolDecl{
{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)},
{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要"), Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string","description":"子代理角色名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"}},"required":["name","task"]}`)},
{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)},
{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)},
// runtime.entry=squad 时对话本身就是小队入口,不再提供 squad_orchestrate。
decls := []agentllm.ToolDecl{}
if squadToolEnabled(eff) {
decls = append(decls, agentllm.ToolDecl{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)})
}
decls = append(decls,
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要"), Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string","description":"子代理角色名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"}},"required":["name","task"]}`)},
agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)},
agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)},
)
cardflow := eff.GetBool("ui.cardflow", false)
if cardflow {
decls = append(decls, agentllm.ToolDecl{
@@ -286,6 +290,11 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 {
streamOpts.MaxTokens = *prof.Budget.MaxTokens
}
defer func() {
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(usageTotal)
}
}()
toolCallsMade := 0
thinkingIdx, textIdx := 0, 0
deliverableGuardUsed := false
@@ -644,6 +653,14 @@ func currentUserText(rc RunCtx) string {
func toolList(rc RunCtx) []core.ToolDesc { return rc.Tools() }
// squadToolEnabled 在小队已经是对话入口时关掉 squad_orchestrate,避免再套一层编排。
func squadToolEnabled(eff core.EffectiveSettings) bool {
if eff.GetString("runtime.entry", "direct") == "squad" {
return false
}
return eff.GetBool("runtime.squad", true)
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
+201
View File
@@ -0,0 +1,201 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"agent-native/exec"
agentllm "agent-native/runtime/llm"
"agent-squad/squad"
"agent-system-backend/internal/squadadapter"
core "agent-system-core"
)
// SquadRunner 是 runtime.entry=squad 时的对话入口。
// 小队先分诊:简单任务由 assistant 单步完成,复杂任务交给 leader。步骤都跑在内核上。
type SquadRunner struct {
Stream agentllm.StreamFunc
Router squad.Router
Checker squad.Checker
Hitl squad.HitlPolicy
}
func (s *SquadRunner) Name() string { return "squad" }
func (s *SquadRunner) Execute(rc RunCtx) error {
if rc.Context().Err() != nil {
return rc.Context().Err()
}
prof := rc.Profile()
maxTurns := 12
if prof.Budget.MaxTurns != nil && *prof.Budget.MaxTurns > 0 {
maxTurns = *prof.Budget.MaxTurns
}
maxTools := 0
if prof.Budget.MaxToolCalls != nil && *prof.Budget.MaxToolCalls > 0 {
maxTools = *prof.Budget.MaxToolCalls
}
produced := &artifactList{}
var streamed strings.Builder
var toolParts []core.Part
textIdx := 0
onEvent := func(ev exec.ProcessEvent) {
switch ev.Type {
case "text_delta":
if ev.Text == "" {
return
}
streamed.WriteString(ev.Text)
_ = rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: ev.Text})
case "reasoning_delta":
if ev.Text != "" {
_ = rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
}
case "tool_execution_start":
_ = rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail})
toolParts = append(toolParts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail}})
case "tool_execution_end":
_ = rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)})
toolParts = append(toolParts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}})
case "usage":
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(core.Usage{PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens})
}
}
}
router := s.Router
if router == nil {
router = newModelRouter(rc)
}
res, err := squadadapter.RunEntry(rc, squadadapter.EntryOptions{
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
Goal: entryGoal(rc),
HostTools: bridgeHostTools(rc, produced),
OnEvent: onEvent,
Stream: s.Stream,
Router: router,
Checker: s.Checker,
Hitl: s.Hitl,
MaxTurns: maxTurns,
MaxToolCalls: maxTools,
Model: ResolveChatModel(prof, rc.Settings()).Model,
})
if rc.Context().Err() != nil {
return rc.Context().Err()
}
if err != nil {
return err
}
reply := strings.TrimSpace(streamed.String())
if reply == "" {
reply = strings.TrimSpace(res.Summary)
}
if reply == "" {
reply = "已完成。"
}
if claimsFalseDeliverable(reply, produced.items, rc) {
return fmt.Errorf("声称已生成文件,但没有登记对应工件")
}
if strings.TrimSpace(res.Body) != "" {
if _, werr := rc.WriteArtifact(res.Name, "document", "text/markdown", []byte(res.Body)); werr != nil {
return werr
}
}
parts := append([]core.Part{}, toolParts...)
parts = append(parts, core.Part{Type: core.PartText, Text: reply})
for _, ref := range produced.items {
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
}
return rc.SetAssistantParts(parts)
}
func entryGoal(rc RunCtx) string {
var b strings.Builder
b.WriteString(currentUserText(rc))
if listed, ok := rc.(interface{ WorkspaceInputs() []string }); ok {
if paths := listed.WorkspaceInputs(); len(paths) > 0 {
b.WriteString("\n工作区 inputs:")
b.WriteString(strings.Join(paths, "、"))
}
}
names := make([]string, 0, len(rc.Attachments()))
for _, a := range rc.Attachments() {
if a.Meta.Filename != "" {
names = append(names, a.Meta.Filename)
}
}
if len(names) > 0 {
b.WriteString("\n附件:")
b.WriteString(strings.Join(names, "、"))
}
return b.String()
}
type modelRouter struct {
stream agentllm.StreamFunc
model string
}
func newModelRouter(rc RunCtx) modelRouter {
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
if route.Model == "" || route.Base == "" {
return modelRouter{}
}
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
return modelRouter{stream: client.Stream, model: route.Model}
}
func (r modelRouter) Route(ctx context.Context, goal squad.Goal) (*squad.RouteDecision, error) {
if r.stream == nil || r.model == "" {
return &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "未配置分诊模型,按复杂任务处理"}, nil
}
prompt := `只输出一个 JSON 对象,不要解释。字段 mode 取 direct 或 plan,family 取 minutes、report、proposal 或 none,reason 用一句话。简单问答和单步改写用 direct。需要成文、多步调研或会议材料用 plan。目标:` + goal.Text
var text string
for ev := range r.stream(ctx, r.model, []agentllm.Message{{Role: agentllm.RoleUser, Content: prompt}}, nil, agentllm.StreamOptions{}) {
switch ev.Kind {
case agentllm.KindTextDelta:
text += ev.Text
case agentllm.KindMessageEnd:
if ev.Message.Content != "" {
text = ev.Message.Content
}
case agentllm.KindError:
return &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "分诊调用失败,按复杂任务处理"}, nil
}
}
return parseRoute(text), nil
}
func parseRoute(text string) *squad.RouteDecision {
dec := &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "分诊结果无法解析,按复杂任务处理"}
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start < 0 || end <= start {
return dec
}
var raw struct {
Mode string `json:"mode"`
Family string `json:"family"`
Reason string `json:"reason"`
}
if json.Unmarshal([]byte(text[start:end+1]), &raw) != nil {
return dec
}
switch raw.Mode {
case string(squad.RouteDirect):
dec.Mode = squad.RouteDirect
case string(squad.RoutePlan):
dec.Mode = squad.RoutePlan
default:
return dec
}
if raw.Family != "" {
dec.Family = raw.Family
}
if raw.Reason != "" {
dec.Reason = raw.Reason
}
return dec
}
+463
View File
@@ -0,0 +1,463 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
agentllm "agent-native/runtime/llm"
"agent-squad/squad"
core "agent-system-core"
)
const squadGoodDoc = "# 应先把结论写进标题\n\n一句话:范围已经明确,按会议记录执行。\n\n依据来自附件里的原话。\n"
type fixedRoute struct{ mode squad.RouteMode }
func (r fixedRoute) Route(ctx context.Context, goal squad.Goal) (*squad.RouteDecision, error) {
return &squad.RouteDecision{Mode: r.mode, Family: "minutes", Reason: "测试"}, nil
}
type failCheck struct{}
func (failCheck) Check(req squad.CheckRequest) *squad.QualityReport {
return &squad.QualityReport{Passed: false, Score: 10, Violations: []string{"缺结论"}}
}
type entryFake struct {
ctx context.Context
prof core.AgentProfile
settings core.EffectiveSettings
text string
inputs []string
files []string
tools []core.ToolDesc
sandbox string
events []core.StreamEventType
parts []core.Part
hitl int
wrote []string
called []string
usage core.Usage
cards int
}
func (f *entryFake) Context() context.Context {
if f.ctx == nil {
return context.Background()
}
return f.ctx
}
func (f *entryFake) Run() core.Run {
return core.Run{ID: "run_t", Owner: "owner", ConversationID: "conv"}
}
func (f *entryFake) Profile() core.AgentProfile { return f.prof }
func (f *entryFake) Settings() core.EffectiveSettings { return f.settings }
func (f *entryFake) Emit(typ core.StreamEventType, payload any) error {
f.events = append(f.events, typ)
return nil
}
func (f *entryFake) SetAssistantParts(parts []core.Part) error { f.parts = parts; return nil }
func (f *entryFake) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
f.hitl++
return core.HITLResponse{ChoiceID: "approve"}, nil
}
func (f *entryFake) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
return core.AgentResultPayload{}, errors.New("no spawn")
}
func (f *entryFake) UpdatePlan(p core.PlanSnapshot) error { return nil }
func (f *entryFake) UpdateTodos(l core.TodoList) error { return nil }
func (f *entryFake) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
f.wrote = append(f.wrote, name)
return core.Artifact{ID: "art_aaaaaaaaaaaa", Name: name, Kind: kind}, nil
}
func (f *entryFake) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
func (f *entryFake) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
f.called = append(f.called, call.Name)
if call.Name == "register_artifact" {
return core.ToolCallResult{Status: "ok", Output: "已登记", Artifact: &core.Artifact{ID: "art_bbbbbbbbbbbb", Name: "纪要.docx", Kind: "document"}}
}
return core.ToolCallResult{Status: "ok", Output: "ok"}
}
func (f *entryFake) History() []core.Message {
return []core.Message{{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: f.text}}}}
}
func (f *entryFake) Attachments() []core.ParsedDocument {
return []core.ParsedDocument{{Meta: core.DocMeta{Filename: "notes.docx"}}}
}
func (f *entryFake) Tools() []core.ToolDesc { return f.tools }
func (f *entryFake) Subtask() string { return "" }
func (f *entryFake) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
f.cards++
card.ID = "card_1"
return card, nil
}
func (f *entryFake) SetCardStatus(id string, status core.CardStatus, tone, note string) error {
return nil
}
func (f *entryFake) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) {
return f.CreateCard(card)
}
func (f *entryFake) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
return f.CreateCard(card)
}
func (f *entryFake) AppendStreamCard(text string) error { return nil }
func (f *entryFake) CommitStreamCard() (core.ActivityCard, error) {
f.cards++
return core.ActivityCard{ID: "card_1", Title: "流式"}, nil
}
func (f *entryFake) Card(id string) *core.ActivityCard { return nil }
func (f *entryFake) Artifact(id string) (core.Artifact, []byte, bool) {
return core.Artifact{}, nil, false
}
func (f *entryFake) SetContextObservation(o core.ContextObservation) {}
func (f *entryFake) WorkspaceInputs() []string { return f.inputs }
func (f *entryFake) ConversationSandbox() string { return f.sandbox }
func (f *entryFake) AddUsage(u core.Usage) {
f.usage.PromptTokens += u.PromptTokens
f.usage.CompletionTokens += u.CompletionTokens
}
func writeThenStop(model *string, calls *int) agentllm.StreamFunc {
var mu sync.Mutex
n := 0
body, _ := json.Marshal(map[string]string{"path": "output.md", "content": squadGoodDoc})
return func(ctx context.Context, m string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 4)
go func() {
defer close(ch)
if ctx.Err() != nil {
ch <- agentllm.Event{Kind: agentllm.KindError, ErrorMessage: ctx.Err().Error()}
return
}
mu.Lock()
n++
step := n
mu.Unlock()
if model != nil {
*model = m
}
if calls != nil {
*calls = step
}
if step%2 == 1 {
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: "结论应先写进标题。"}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{
Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: string(body)}},
}, Usage: agentllm.Usage{PromptTokens: 4, CompletionTokens: 2}}
return
}
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: "写完了。"}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{
Role: agentllm.RoleAssistant, Content: "写完了。", StopReason: agentllm.StopEnd,
}, Usage: agentllm.Usage{PromptTokens: 3, CompletionTokens: 1}}
}()
return ch
}
}
func TestSquadEntryStreamsAndRegisters(t *testing.T) {
dir := t.TempDir()
var model string
rc := &entryFake{
text: "整理会议结论",
inputs: []string{filepath.Join(dir, "inputs", "notes.docx")},
sandbox: dir,
settings: core.EffectiveSettings{
"storage": map[string]any{"dataDir": dir},
"models": map[string]any{"chat": map[string]any{"model": "base", "baseUrl": "http://test", "apiKey": "k"}},
"ui": map[string]any{"cardflow": true},
},
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "profile-model"}},
tools: []core.ToolDesc{{Name: "register_artifact", Description: "登记", Parameters: json.RawMessage(`{"type":"object"}`)}},
}
cardBody, _ := json.Marshal(map[string]string{"title": "纪要", "body": "结论应先写。"})
regBody, _ := json.Marshal(map[string]string{"path": "output.md"})
runner := &SquadRunner{
Router: fixedRoute{mode: squad.RouteDirect},
Checker: squad.MinimalChecker{},
Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled},
Stream: scriptedCalls(t, &model, [][]agentllm.Event{
{
{Kind: agentllm.KindTextDelta, Text: "结论应先写进标题。"},
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{
{ID: "c", Name: "submit_card", Arguments: string(cardBody)},
}}, Usage: agentllm.Usage{PromptTokens: 2, CompletionTokens: 1}},
},
{
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{
{ID: "r", Name: "register_artifact", Arguments: string(regBody)},
}}, Usage: agentllm.Usage{PromptTokens: 2, CompletionTokens: 1}},
},
{
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{
{ID: "w", Name: "write_file", Arguments: mustDocArgs()},
}}, Usage: agentllm.Usage{PromptTokens: 2, CompletionTokens: 1}},
},
{
{Kind: agentllm.KindTextDelta, Text: "写完了。"},
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "写完了。", StopReason: agentllm.StopEnd}, Usage: agentllm.Usage{PromptTokens: 1, CompletionTokens: 1}},
},
}),
}
if err := runner.Execute(rc); err != nil {
t.Fatal(err)
}
if model != "profile-model" {
t.Fatalf("model = %s", model)
}
if !containsEvent(rc.events, core.EvMessageDelta) || !containsEvent(rc.events, core.EvToolCall) {
t.Fatalf("events = %v", rc.events)
}
if rc.cards == 0 || !containsStr(rc.called, "register_artifact") {
t.Fatalf("cards=%d tools=%v", rc.cards, rc.called)
}
if rc.usage.PromptTokens == 0 {
t.Fatal("usage not recorded")
}
if len(rc.wrote) == 0 {
t.Fatal("deliverable was not registered")
}
task := filepath.Join(dir, "squad-entry", "run_t")
found := false
_ = filepath.Walk(task, func(path string, info os.FileInfo, err error) error {
if err == nil && strings.HasSuffix(path, "TASK.md") {
b, _ := os.ReadFile(path)
if strings.Contains(string(b), "inputs") && strings.Contains(string(b), "notes.docx") {
found = true
}
}
return nil
})
if !found {
t.Fatal("goal did not carry inputs and attachment name")
}
}
func TestSquadEntryFalseDeliverableAndBudgets(t *testing.T) {
dir := t.TempDir()
rc := baseEntry(dir)
claim := &SquadRunner{
Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{},
Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled},
Stream: scriptedCalls(t, nil, [][]agentllm.Event{
{{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: mustDocArgs()}}}}},
{{Kind: agentllm.KindTextDelta, Text: "已生成 纪要.docx"}, {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "已生成 纪要.docx", StopReason: agentllm.StopEnd}}},
}),
}
if err := claim.Execute(rc); err == nil || !strings.Contains(err.Error(), "没有登记") {
t.Fatalf("false claim err = %v", err)
}
var calls int
turns := 1
rc2 := baseEntry(dir)
rc2.prof.Budget.MaxTurns = &turns
limited := &SquadRunner{
Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{},
Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled},
Stream: writeThenStop(nil, &calls),
}
// 第一轮只写了别的文件,第二轮才写 output.md;轮次上限为 1 时交付物缺失。
limited.Stream = oneTurnOtherFile(&calls)
if err := limited.Execute(rc2); err == nil {
t.Fatal("turn cap should stop before the deliverable")
}
if calls != 1 {
t.Fatalf("model calls = %d", calls)
}
tools := 1
rc3 := baseEntry(dir)
rc3.prof.Budget.MaxToolCalls = &tools
var toolCalls int
capped := &SquadRunner{
Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{},
Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled},
Stream: twoTools(&toolCalls),
}
if err := capped.Execute(rc3); err == nil || !strings.Contains(err.Error(), "budget_exceeded") {
t.Fatalf("tool cap err = %v", err)
}
}
func TestSquadEntryHITLAndCancel(t *testing.T) {
dir := t.TempDir()
rc := baseEntry(dir)
runner := &SquadRunner{
Router: fixedRoute{mode: squad.RouteDirect}, Checker: failCheck{},
Stream: writeThenStop(nil, nil),
}
if err := runner.Execute(rc); err != nil {
t.Fatal(err)
}
if rc.hitl == 0 {
t.Fatal("quality hold did not ask the user")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
rc2 := baseEntry(dir)
rc2.ctx = ctx
if err := (&SquadRunner{}).Execute(rc2); !errors.Is(err, context.Canceled) {
t.Fatalf("cancel err = %v", err)
}
}
func TestHostBridgeHonorsAllowlist(t *testing.T) {
rc := &entryFake{tools: []core.ToolDesc{
{Name: "register_artifact"},
{Name: "read_file"},
{Name: "knowledge_search"},
}, settings: core.EffectiveSettings{}}
rc.prof.Tools.Deny = []string{"register_artifact"}
names := map[string]bool{}
for _, tool := range bridgeHostTools(rc, nil) {
names[tool.Name()] = true
}
if names["register_artifact"] || names["read_file"] || !names["knowledge_search"] {
t.Fatalf("bridged = %v", names)
}
}
func TestParseRoute(t *testing.T) {
dec := parseRoute("好的 {\"mode\":\"direct\",\"family\":\"report\",\"reason\":\"一句就够\"}")
if dec.Mode != squad.RouteDirect || dec.Family != "report" {
t.Fatalf("%+v", dec)
}
if parseRoute("不是 json").Mode != squad.RoutePlan {
t.Fatal("unparsed route should stay on the plan path")
}
}
func TestSquadToolHiddenWhenEntryIsSquad(t *testing.T) {
eff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad", "squad": true}}
if squadToolEnabled(eff) {
t.Fatal("squad entry must not expose squad_orchestrate")
}
if !squadToolEnabled(core.EffectiveSettings{"runtime": map[string]any{"squad": true}}) {
t.Fatal("direct entry keeps the tool")
}
}
func baseEntry(dir string) *entryFake {
return &entryFake{
text: "整理会议结论",
settings: core.EffectiveSettings{
"storage": map[string]any{"dataDir": dir},
"models": map[string]any{"chat": map[string]any{"model": "m", "baseUrl": "http://test", "apiKey": "k"}},
},
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "m"}},
}
}
func mustDocArgs() string {
b, _ := json.Marshal(map[string]string{"path": "output.md", "content": squadGoodDoc})
return string(b)
}
func scriptedCalls(t *testing.T, model *string, turns [][]agentllm.Event) agentllm.StreamFunc {
t.Helper()
var mu sync.Mutex
i := 0
return func(ctx context.Context, m string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 8)
go func() {
defer close(ch)
mu.Lock()
n := i
if i < len(turns) {
i++
}
mu.Unlock()
if model != nil {
*model = m
}
if n >= len(turns) {
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "done", StopReason: agentllm.StopEnd}}
return
}
for _, ev := range turns[n] {
ch <- ev
}
}()
return ch
}
}
func oneTurnOtherFile(calls *int) agentllm.StreamFunc {
body, _ := json.Marshal(map[string]string{"path": "note.md", "content": "not the deliverable"})
var once sync.Once
return func(ctx context.Context, model string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 2)
go func() {
defer close(ch)
once.Do(func() {
if calls != nil {
*calls = 1
}
})
if calls != nil && *calls == 1 {
*calls = 1
}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{
Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: string(body)}},
}}
}()
return ch
}
}
func twoTools(calls *int) agentllm.StreamFunc {
var mu sync.Mutex
n := 0
return func(ctx context.Context, model string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 2)
go func() {
defer close(ch)
mu.Lock()
n++
step := n
mu.Unlock()
if calls != nil {
*calls = step
}
name := "write_file"
args := mustDocArgs()
if step > 1 {
body, _ := json.Marshal(map[string]string{"path": "note.md", "content": "x"})
args = string(body)
}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{
Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
ToolCalls: []agentllm.ToolCall{{ID: "w", Name: name, Arguments: args}},
}}
}()
return ch
}
}
func containsEvent(events []core.StreamEventType, want core.StreamEventType) bool {
for _, ev := range events {
if ev == want {
return true
}
}
return false
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
+6 -4
View File
@@ -30,10 +30,11 @@ func SystemDefaults() map[string]any {
"tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具)
},
"runtime": map[string]any{
"mock": false, // true = 全部会话走 mock runner(演示/测试)
"squad": true, // 小队编排(Agent-Squad 引擎)可用
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
"mock": false, // true = 全部会话走 mock runner(演示/测试)
"squad": true, // 小队编排(Agent-Squad 引擎)可用
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
},
"storage": map[string]any{
"dataDir": "./data", // 启动注入实际值(--data)
@@ -73,6 +74,7 @@ func Schema() []core.SettingDesc {
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
@@ -46,6 +46,12 @@ dx.add_numbers(doc, ["步骤"]) # 文本里不要再写 1.
dx.add_table(doc, ["列"], [["值"]], ["left"])
dx.add_callout(doc, "给决策者的一段话。", "摘要")
dx.add_quote(doc, "关键原话。", "出处")
from housestyle import visuals
visuals.status_row(doc, [{"title": "已修复", "badge": "历史", "body": "一句说明。", "tone": "ok"}])
visuals.card_grid(doc, [{"title": "判断", "points": ["要点"], "footnote": "一句话总结", "tone": "info"}])
visuals.column_row(doc, [{"title": "要求", "points": ["事项"], "footnote": "收束", "tone": "warn"}])
png = visuals.render_flow("/tmp/flow.png", [{"title": "先入库", "body": "再输出", "tone": "info"}])
visuals.add_picture(doc, png)
dx.save(doc, "交付物.docx")
prs = px.new_presentation(theme="modern", title="汇报")
@@ -97,6 +97,7 @@ def lint_docx(path: str) -> list[dict]:
if "PAGE" not in footer_xml and "页" not in footer_xml:
add("page-number", "error", "页脚没有页码")
break
_check_visual_summary(doc, add)
return issues
@@ -199,6 +200,20 @@ def _heading_level(style_name: str) -> int:
return 0
def _check_visual_summary(doc, add) -> None:
headings = 0
for paragraph in doc.paragraphs:
style = paragraph.style.name if paragraph.style is not None else ""
if _heading_level(style) == 1:
headings += 1
if headings < 4:
return
comments = doc.core_properties.comments or ""
if "visual-summary" in comments or "w:drawing" in doc.element.body.xml:
return
add("visual-summary", "warning", "一级标题已有四节,正文没有视觉摘要(状态卡、网格、流程图或时间线)")
def _check_outline(doc, add) -> None:
blocks = []
for child in doc.element.body:
@@ -0,0 +1,233 @@
"""信息图:可编辑的卡片块,以及用 PIL 渲染的流程图和时间线。
卡片是带底色的表格,用户可以在 Word 里直接改字。流程图是 PNG,
因为箭头和多色胶囊用表格表达不稳定。
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from docx.shared import Cm
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches as PptInches
from housestyle import docx_kit, pptx_kit
from housestyle.themes import resolve_theme
_TONES = {
"ok": ("1F7A4D", "E7F6EE"),
"info": ("1B3A4B", "E7EEF2"),
"warn": ("C05621", "FDF1E7"),
"risk": ("9F1239", "FDE8EE"),
}
def mark_visual(doc) -> None:
# keywords 存的是主题色,不能把标记写进去,否则下一节会把颜色解析失败。
comments = doc.core_properties.comments or ""
if "visual-summary" in comments:
return
doc.core_properties.comments = (comments + " visual-summary").strip()
def status_row(doc, cards: list[dict]):
"""横排状态卡。每张卡:title、badge、body,tone 取 ok/info/warn/risk。"""
spec = docx_kit._theme(doc)
count = max(len(cards), 1)
table = doc.add_table(rows=1, cols=count)
docx_kit._format_table(table, spec, header=False)
width = docx_kit._content_width(doc, spec)
docx_kit._set_widths(table, [width / count] * count)
for index, card in enumerate(cards):
tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft)
body = _join(card.get("title", ""), card.get("badge", ""), card.get("body", ""))
docx_kit._write_cell(table.cell(0, index), body, spec, fill=fill, align="left")
_paint_first_line(table.cell(0, index), spec, tone)
mark_visual(doc)
return table
def card_grid(doc, cards: list[dict]):
"""两列卡片。每张卡:title、points、footnote。"""
spec = docx_kit._theme(doc)
rows = (len(cards) + 1) // 2
table = doc.add_table(rows=rows, cols=2)
docx_kit._format_table(table, spec, header=False)
width = docx_kit._content_width(doc, spec)
docx_kit._set_widths(table, [width / 2, width / 2])
for index, card in enumerate(cards):
tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft)
text = _card_text(card)
cell = table.cell(index // 2, index % 2)
docx_kit._write_cell(cell, text, spec, fill=fill, align="left")
_paint_first_line(cell, spec, tone)
mark_visual(doc)
return table
def column_row(doc, columns: list[dict]):
"""三栏或更多栏的要求卡。每栏:title、points、footnote。"""
spec = docx_kit._theme(doc)
count = max(len(columns), 1)
table = doc.add_table(rows=1, cols=count)
docx_kit._format_table(table, spec, header=False)
width = docx_kit._content_width(doc, spec)
docx_kit._set_widths(table, [width / count] * count)
for index, card in enumerate(columns):
tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft)
cell = table.cell(0, index)
docx_kit._write_cell(cell, _card_text(card), spec, fill=fill, align="left")
_paint_first_line(cell, spec, tone)
mark_visual(doc)
return table
def add_picture(doc, path: str, width_cm: float = 16):
doc.add_picture(path, width=Cm(width_cm))
mark_visual(doc)
def render_flow(path: str, steps: list[dict], theme: str = "modern") -> str:
"""把步骤画成横向流程图 PNG。steps 含 title、body、tone。"""
return _render_pills(path, steps, theme, arrow=True)
def render_timeline(path: str, steps: list[dict], theme: str = "modern") -> str:
"""把节点画成横向时间线 PNG。steps 含 title、body。"""
return _render_pills(path, steps, theme, arrow=False)
def add_card_slide(prs, title: str, cards: list[dict]):
"""演示稿上的卡片页,最多四张。"""
spec = pptx_kit._theme(prs)
slide = prs.slides.add_slide(pptx_kit._blank(prs))
if not pptx_kit._templated(prs):
pptx_kit._title(slide, prs, spec, title)
count = max(len(cards), 1)
gap = PptInches(0.18)
left = PptInches(0.45)
top = PptInches(1.55)
width = (prs.slide_width - PptInches(0.9) - gap * (count - 1)) / count
height = PptInches(4.8)
for index, card in enumerate(cards):
tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft)
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left + (width + gap) * index, top, width, height)
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor.from_string(fill)
shape.line.fill.background()
frame = shape.text_frame
frame.word_wrap = True
lines = _card_lines(card)
for i, line in enumerate(lines):
paragraph = frame.paragraphs[0] if i == 0 else frame.add_paragraph()
run = paragraph.add_run()
run.text = line
pptx_kit._run_font(run, spec.heading_font if i == 0 else spec.font, 16 if i == 0 else 13, i == 0, tone if i == 0 else spec.text)
if not pptx_kit._templated(prs):
pptx_kit._number(slide, prs, spec, len(prs.slides))
comments = prs.core_properties.comments or ""
if "visual-summary" not in comments:
prs.core_properties.comments = (comments + " visual-summary").strip()
return slide
def _tone(name: str, accent: str, soft: str) -> tuple[str, str]:
if name in _TONES:
return _TONES[name]
return accent, soft
def _join(*parts: str) -> str:
return "\n".join(part for part in parts if part)
def _card_text(card: dict) -> str:
return "\n".join(_card_lines(card))
def _card_lines(card: dict) -> list[str]:
lines = []
if card.get("title"):
lines.append(str(card["title"]))
if card.get("badge"):
lines.append(str(card["badge"]))
for point in card.get("points") or []:
lines.append(str(point))
if card.get("body"):
lines.append(str(card["body"]))
if card.get("footnote"):
lines.append(str(card["footnote"]))
return lines or [" "]
def _paint_first_line(cell, spec, color: str) -> None:
paragraph = cell.paragraphs[0]
for run in paragraph.runs:
docx_kit._font_run(run, spec.heading_font, spec.font_latin, spec.body_pt, True, color)
def _render_pills(path: str, steps: list[dict], theme: str, arrow: bool) -> str:
spec = resolve_theme(theme)
count = max(len(steps), 1)
width, height = 360 * count, 420
image = Image.new("RGB", (width, height), "#FFFFFF")
draw = ImageDraw.Draw(image)
font = _font(22)
small = _font(16)
for index, step in enumerate(steps):
tone, fill = _tone(step.get("tone") or "info", spec.accent, spec.accent_soft)
x = 24 + index * 360
draw.rounded_rectangle((x, 40, x + 300, 360), radius=18, fill="#" + fill, outline="#" + tone, width=3)
title = str(step.get("title") or "")
body = str(step.get("body") or "")
draw.text((x + 18, 64), _wrap(title, 12), font=font, fill="#" + tone)
draw.text((x + 18, 150), _wrap(body, 14), font=small, fill="#" + spec.text)
if arrow and index < len(steps) - 1:
draw.line((x + 308, 190, x + 348, 190), fill="#" + spec.accent, width=4)
Path(path).parent.mkdir(parents=True, exist_ok=True)
image.save(path, "PNG")
return path
def _wrap(text: str, width: int) -> str:
text = " ".join(text.split())
if not text:
return ""
lines = []
current = ""
for char in text:
current += char
if len(current) >= width:
lines.append(current)
current = ""
if len(lines) >= 6:
break
if current and len(lines) < 6:
lines.append(current)
return "\n".join(lines)
def _font(size: int):
path = _cjk_font_path()
if path:
try:
return ImageFont.truetype(path, size)
except OSError:
pass
return ImageFont.load_default()
def _cjk_font_path() -> str | None:
try:
out = subprocess.check_output(["fc-list", ":lang=zh", "file"], text=True, timeout=3)
except (OSError, subprocess.TimeoutExpired):
return None
for line in out.splitlines():
candidate = line.split(":", 1)[0].strip()
if candidate.lower().endswith((".ttf", ".otf", ".ttc")):
return candidate
return None
@@ -5,7 +5,7 @@ from pathlib import Path
from docx import Document
from docx.shared import RGBColor
from housestyle import docx_kit, pptx_kit, resolve_theme, summarize, xlsx_kit
from housestyle import docx_kit, pptx_kit, resolve_theme, summarize, visuals, xlsx_kit
from housestyle.extract import extract
from housestyle.lint import lint_docx, lint_pptx, lint_xlsx
@@ -120,5 +120,51 @@ class BadDocumentTests(unittest.TestCase):
self.assertTrue(expected <= rules, sorted(expected - rules))
class VisualTests(unittest.TestCase):
def test_cards_mark_visual_and_long_doc_warns_without_them(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
plain = docx_kit.new_document("modern", title="长文")
docx_kit.add_title(plain, "长文", "2026年10月1日")
for title in ("先判断相关性", "再打标签入库", "然后比对基线", "最后确认变更"):
docx_kit.add_heading(plain, title, 1)
docx_kit.add_paragraph(plain, "这一节有实质内容,但没有视觉摘要。")
plain_path = root / "plain.docx"
docx_kit.save(plain, str(plain_path))
rules = {item["rule"] for item in lint_docx(str(plain_path)) if item["severity"] == "warning"}
self.assertIn("visual-summary", rules)
visual = docx_kit.new_document("modern", title="长文")
docx_kit.add_title(visual, "长文", "2026年10月1日")
visuals.status_row(visual, [
{"title": "已修复", "badge": "历史", "body": "上传可以不带指令。", "tone": "ok"},
{"title": "当前逻辑", "badge": "现状", "body": "先摘要再提示缺口。", "tone": "info"},
{"title": "待调整", "badge": "建议", "body": "先入库再输出。", "tone": "warn"},
])
for title in ("先判断相关性", "再打标签入库", "然后比对基线", "最后确认变更"):
docx_kit.add_heading(visual, title, 1)
docx_kit.add_paragraph(visual, "这一节有实质内容。")
visual_path = root / "visual.docx"
docx_kit.save(visual, str(visual_path))
warned = {item["rule"] for item in lint_docx(str(visual_path))}
self.assertNotIn("visual-summary", warned)
self.assertEqual(_errors(lint_docx(str(visual_path))), [])
png = visuals.render_flow(str(root / "flow.png"), [
{"title": "判断相关", "body": "存疑先问人", "tone": "info"},
{"title": "打标签", "body": "切片入库", "tone": "ok"},
])
self.assertGreater(Path(png).stat().st_size, 500)
prs = pptx_kit.new_presentation("modern", title="卡片")
visuals.add_card_slide(prs, "三种处理", [
{"title": "摘要", "points": ["结论先行"], "tone": "info"},
{"title": "笔记", "points": ["保留原意"], "tone": "ok"},
])
ppt = root / "cards.pptx"
pptx_kit.save(prs, str(ppt))
self.assertEqual(_errors(lint_pptx(str(ppt))), [])
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -21,6 +21,7 @@ import (
"agent-native/exec"
"agent-native/runtime/subagent"
"agent-squad/squad"
"agent-system-backend/internal/quality"
core "agent-system-core"
)
@@ -79,6 +80,8 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) {
DefaultBackend: "agent-system",
StepTimeout: 5 * time.Minute,
HitlPolicy: squad.HitlPolicy{Mode: squad.HitlInteractive},
Checker: quality.Checker{},
RepairBudget: 2,
Sink: squad.ProgressFunc(func(ev squad.Event) { onSquadEvent(rc, ev) }),
})
if err != nil {
+230
View File
@@ -0,0 +1,230 @@
package squadadapter
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"agent-native/exec"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/tools"
"agent-squad/squad"
"agent-system-backend/internal/quality"
)
// EntryOptions 是小队作为对话入口时的一次运行参数。
type EntryOptions struct {
DataDir string
Goal string
HostTools []tools.Tool
OnEvent func(ev exec.ProcessEvent)
Stream agentllm.StreamFunc
Router squad.Router
Checker squad.Checker
Hitl squad.HitlPolicy
MaxTurns int
MaxToolCalls int
RepairBudget int
Model string
}
// EntryResult 是入口跑完后的文本。工件登记由调用方在虚假交付检查之后做。
type EntryResult struct {
Summary string
Body string
Name string
}
// RunEntry 用小队引擎跑完一条用户目标。简单任务是单步 assistant,复杂任务进 leader。
// 每一步都走 agent-native 内核。
func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) {
if strings.TrimSpace(opt.Goal) == "" {
return EntryResult{}, fmt.Errorf("squad entry: goal text is empty")
}
cfg, enabled := squad.AgentNativeConfig{}, false
if AgentNativeOptions != nil {
cfg, enabled = AgentNativeOptions(rc)
}
if opt.Model != "" {
cfg.DefaultModel = opt.Model
}
if opt.Stream != nil {
cfg.Stream = opt.Stream
if cfg.DefaultModel == "" {
cfg.DefaultModel = "test"
}
enabled = true
}
if !enabled || cfg.DefaultModel == "" {
return EntryResult{}, fmt.Errorf("squad entry: 主模型未配置")
}
cfg.HostTools = opt.HostTools
cfg.OnEvent = opt.OnEvent
if opt.MaxTurns > 0 {
cfg.MaxTurns = opt.MaxTurns
}
if cfg.MaxTurns <= 0 {
cfg.MaxTurns = 12
}
cfg.MaxToolCalls = opt.MaxToolCalls
hostNames := make([]string, 0, len(opt.HostTools))
for _, tool := range opt.HostTools {
hostNames = append(hostNames, tool.Name())
}
registry := squad.NewRoleRegistry()
for _, role := range kernelRoles(roleCaps{
host: hostNames, exec: cfg.Exec != nil, skills: len(cfg.SkillsRoots) > 0,
search: cfg.Search != nil, fetch: cfg.Fetch != nil,
}) {
if err := registry.Register(role); err != nil {
return EntryResult{}, fmt.Errorf("squad entry: register %s: %w", role.Role, err)
}
}
backends := buildBackends(rc)
backends.RegisterAgentNativeFull(cfg)
checker := opt.Checker
if checker == nil {
checker = quality.Checker{}
}
repair := opt.RepairBudget
if repair <= 0 {
repair = 2
}
runDir := filepath.Join(opt.DataDir, "squad-entry", rc.Run().ID)
engine, err := squad.NewEngine(squad.Options{
DataDir: runDir,
Registry: registry,
Planner: squad.NewRulePlanner(),
Backends: backends,
DefaultBackend: "agent-native",
StepTimeout: 5 * time.Minute,
HitlPolicy: opt.Hitl,
Checker: checker,
RepairBudget: repair,
Router: opt.Router,
Sink: squad.ProgressFunc(func(ev squad.Event) { onSquadEvent(rc, ev) }),
})
if err != nil {
return EntryResult{}, fmt.Errorf("squad entry: engine: %w", err)
}
res, err := engine.Run(rc.Context(), squad.Goal{Text: opt.Goal}, squad.RunOptions{})
for err == nil && res != nil && !squad.RunStatus(res.Status).Terminal() {
switch {
case isHoldStatus(string(res.Status)):
req := res.Pending
if req == nil {
if req, err = engine.Pending(res.RunID); err != nil || req == nil {
break
}
}
decision, aerr := askHuman(rc, req)
if aerr != nil {
return EntryResult{}, aerr
}
res, err = engine.Respond(rc.Context(), res.RunID, decision)
default:
res, err = engine.Resume(rc.Context(), res.RunID)
}
if err != nil {
break
}
if res.Plan != nil {
_ = rc.UpdatePlan(mapPlan(res.Plan))
}
}
if err != nil {
return EntryResult{}, fmt.Errorf("squad entry: %w", err)
}
if res == nil {
return EntryResult{}, fmt.Errorf("squad entry: empty result")
}
if res.Plan != nil {
_ = rc.UpdatePlan(mapPlan(res.Plan))
}
if res.Status != squad.RunCompleted && res.Status != squad.RunDegraded {
return EntryResult{Summary: res.Summary}, fmt.Errorf("squad entry: status %s: %s", res.Status, res.Error)
}
name := "squad-entry.md"
if res.Deliverable != "" && !strings.Contains(res.Deliverable, "..") {
name = filepath.Base(res.Deliverable)
}
return EntryResult{Summary: res.Summary, Body: readDeliverable(res), Name: name}, nil
}
func readDeliverable(res *squad.RunResult) string {
if res.WorkDir != "" && res.Deliverable != "" && !strings.Contains(res.Deliverable, "..") {
path := filepath.Join(res.WorkDir, filepath.FromSlash(res.Deliverable))
if b, err := os.ReadFile(path); err == nil {
return string(b)
}
}
return res.Deliverable
}
type roleCaps struct {
host []string
exec bool
skills bool
search bool
fetch bool
}
func kernelRoles(caps roleCaps) []squad.RoleSpec {
base := []string{"read_file", "write_file", "list_dir", "grep"}
writerTools := append([]string{}, base...)
writerTools = append(writerTools, "edit_file", "multi_edit", "glob")
if caps.exec {
writerTools = append(writerTools, "run_python", "run_command")
}
if caps.skills {
writerTools = append(writerTools, "load_skill")
}
writerTools = append(writerTools, caps.host...)
research := append([]string{}, base...)
if caps.search {
research = append(research, "web_search")
}
if caps.fetch {
research = append(research, "fetch_url")
}
instr := func(kind, fallback string) string {
if RoleInstructions != nil {
if body, ok := RoleInstructions(kind); ok && strings.TrimSpace(body) != "" {
return body
}
}
return fallback
}
writerBash := squad.BashPermissionDeny
if caps.exec {
writerBash = squad.BashPermissionAsk
}
writerSkills := []string(nil)
if caps.skills {
writerSkills = []string{"docx-author", "xlsx-author", "pptx-author"}
}
return []squad.RoleSpec{
{Role: "leader", Name: "组长", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "write_file"}, Instructions: instr("leader", "先判断任务该直通还是拆开。复杂任务每次只委派一个成员,并写明技能与最低分。")},
{Role: "assistant", Name: "助手", Backend: "agent-native", BashPermission: writerBash,
Tools: writerTools, Skills: writerSkills,
Instructions: instr("assistant", "直接完成用户的这一件事。需要成文时按交付物工作流生成并登记,不要只在回复里声称文件已生成。")},
{Role: "planner", Name: "规划员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: base, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")},
{Role: "researcher", Name: "调研员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: research, Instructions: instr("researcher", "围绕目标调研关键问题,输出简明结论与依据。")},
{Role: "writer", Name: "撰稿员", Backend: "agent-native", BashPermission: writerBash,
Tools: writerTools, Skills: writerSkills, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")},
{Role: "analyst", Name: "分析员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: append(append([]string{}, base...), caps.host...),
Instructions: instr("analyst", "按现象、影响、隐性顾虑、建议四层写洞察。开头给一句话洞察,结尾给下一步行动。")},
{Role: "reviewer", Name: "审校员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: append(append([]string{}, base...), caps.host...),
Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
}
}
+51
View File
@@ -0,0 +1,51 @@
package tools
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"agent-system-backend/internal/quality"
core "agent-system-core"
)
func registerQualityReview(r *Registry) error {
return r.Register(core.ToolDesc{
Name: "quality_review",
Description: "按结论先行、结构、完整性、证据、可执行性、可读性给交付物打分,并附上版式检查。不通过时按 violations 改一版再登记",
Source: core.SourceBuiltin,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"会话工作区内的相对路径"}},"required":["path"]}`),
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
path, err := workspaceFile(rc.WorkspaceRoot(), a.Path)
if err != nil {
return "", err
}
report := quality.ScoreFile(path)
out, err := json.Marshal(report)
if err != nil {
return "", err
}
return string(out), nil
})
}
func workspaceFile(root, rel string) (string, error) {
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("quality_review: workspace is not configured")
}
if rel == "" || filepath.IsAbs(rel) {
return "", fmt.Errorf("quality_review: path must be a relative path inside the workspace")
}
clean := filepath.Clean(rel)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("quality_review: path escapes the workspace")
}
return filepath.Join(root, clean), nil
}
+52
View File
@@ -0,0 +1,52 @@
package tools
import (
"encoding/json"
"os"
"path/filepath"
"testing"
core "agent-system-core"
)
type qualityCtx struct{ root string }
func (c qualityCtx) ProjectID() string { return "p" }
func (c qualityCtx) ConversationID() string { return "c" }
func (c qualityCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) {
return nil, nil
}
func (c qualityCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) {
return core.Artifact{}, nil
}
func (c qualityCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil }
func (c qualityCtx) PeerConversationMessages(string, int) ([]core.Message, error) {
return nil, nil
}
func (c qualityCtx) OwnerID() string { return "o" }
func (c qualityCtx) WorkspaceRoot() string { return c.root }
func (c qualityCtx) SettingBool(string, bool) bool { return false }
func TestQualityReviewStaysInsideWorkspace(t *testing.T) {
dir := t.TempDir()
body := "# 上传应先入库再生成\n\n## 判断\n说话人1认为要先看相关性。\n一句话:先建记忆。\n\n## 行动\n负责人陈威,期限周五;不清楚的写未指明。这份说明写长一点,保证完整性维度能够通过,因为过短的正文会被当成没有覆盖决定。需要把依据、行动和收束都写清楚,读者只看标题也知道下一步是什么。\n"
if err := os.WriteFile(filepath.Join(dir, "note.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
reg := NewRegistry()
if err := reg.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
res := reg.Execute(qualityCtx{root: dir}, core.ToolCallRequest{
CallID: "q", Name: "quality_review", Arguments: json.RawMessage(`{"path":"note.md"}`),
})
if res.Status != "ok" {
t.Fatalf("review: %s", res.Output)
}
escaped := reg.Execute(qualityCtx{root: dir}, core.ToolCallRequest{
CallID: "q2", Name: "quality_review", Arguments: json.RawMessage(`{"path":"../secret.md"}`),
})
if escaped.Status != "error" {
t.Fatalf("escape should fail, got %s", escaped.Output)
}
}
+3
View File
@@ -183,5 +183,8 @@ func (r *Registry) RegisterBuiltin() error {
}); err != nil {
return err
}
if err := registerQualityReview(r); err != nil {
return err
}
return registerPeerTools(r)
}