Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421fee81c3 | ||
|
|
72b2098030 | ||
|
|
e03d3ac6c6 | ||
|
|
c802be812d | ||
|
|
9a531a61d9 | ||
|
|
9c4edc4dc9 | ||
|
|
9786dc1456 | ||
|
|
ef060d4ee5 | ||
|
|
1a3fbfb3d8 | ||
|
|
6be8980c17 | ||
|
|
5b9406dd91 | ||
|
|
77da63edac | ||
|
|
aa8eaf3f01 | ||
|
|
52775178dc | ||
|
|
1a550895c3 | ||
|
|
4badecbf57 | ||
|
|
eb697ab1c0 | ||
|
|
db5efc9b83 | ||
|
|
31965fd837 | ||
|
|
5544a0304d | ||
|
|
8157e68cf1 | ||
|
|
6337cd87f8 | ||
|
|
822cda2833 | ||
|
|
8c17a2228b | ||
|
|
545dc404e5 | ||
|
|
81da190ac1 | ||
|
|
020926234e | ||
|
|
9fa1787d01 | ||
|
|
59f62cace1 | ||
|
|
439865616a | ||
|
|
4675a25c18 | ||
|
|
449d8a6943 | ||
|
|
9fe2a592f3 | ||
|
|
908fb253d7 | ||
|
|
43297795f5 | ||
|
|
c0b52ac8bd | ||
|
|
b68833f320 | ||
|
|
d88dcb999f | ||
|
|
613fb27ba0 | ||
|
|
989379e924 | ||
|
|
f1c9cc0644 | ||
|
|
68c1a7272a | ||
|
|
399e2e53e6 | ||
|
|
92ede3b970 | ||
|
|
46c2abbca8 | ||
|
|
bbe39b475f | ||
|
|
6162817ea3 | ||
|
|
182d8b5143 | ||
|
|
8a9a354649 | ||
|
|
1de663a8d3 | ||
|
|
d3381b208b | ||
|
|
c5575aab27 | ||
|
|
fbc924c240 | ||
|
|
1ee32822c3 | ||
|
|
251c204a6e | ||
|
|
a4783d18e7 | ||
|
|
d89e74880b | ||
|
|
e0b1c27f1f | ||
|
|
a7cc69b61e | ||
|
|
fe7c245f5c | ||
|
|
aa03de4045 | ||
|
|
4791b2e760 | ||
|
|
196c8c09f7 | ||
|
|
2e42ed83f0 | ||
|
|
7642dcec24 | ||
|
|
76b9cf36a8 | ||
|
|
70afe40b00 | ||
|
|
a1aba1527a | ||
|
|
017c96baa5 | ||
|
|
06934bccab | ||
|
|
30bbf5483f | ||
|
|
6e02a1f574 | ||
|
|
26bd9286ad | ||
|
|
31a4e7576a | ||
|
|
e59b25c816 | ||
|
|
8c5612a0a3 | ||
|
|
e5034e177d | ||
|
|
766a96e817 | ||
|
|
80a7468523 | ||
|
|
f862892544 | ||
|
|
c392ed81ee | ||
|
|
904742ca4b | ||
|
|
dcd7c9a444 | ||
|
|
9f3fdb26e2 | ||
|
|
7a6a6075c2 | ||
|
|
1770fac51f | ||
|
|
97bc5093bd | ||
|
|
7980a8a219 | ||
|
|
358cfe017c | ||
|
|
b44f78b09a | ||
|
|
df6745e820 | ||
|
|
48c5202de2 | ||
|
|
1291b378c0 | ||
|
|
7365b4c471 | ||
|
|
5dfe70d68d | ||
|
|
7aa3bc447d | ||
|
|
8858ca9b8a | ||
|
|
7acbefd60d | ||
|
|
68addfda05 | ||
|
|
fcf2c73589 | ||
|
|
33e261b224 | ||
|
|
fb90e6239a | ||
|
|
9d2937115c | ||
|
|
3f5c0f60e4 | ||
|
|
45b011af9f | ||
|
|
3261135126 | ||
|
|
3de400b740 | ||
|
|
5883d10889 | ||
|
|
58d9a17b8a | ||
|
|
7ea21c3ca2 | ||
|
|
15fb79e30e | ||
|
|
a59980898f | ||
|
|
617a4566be | ||
|
|
6f27567acd | ||
|
|
64e80c9bb6 | ||
|
|
40ebca7f07 | ||
|
|
12f50ef2dc | ||
|
|
0a26fb598b | ||
|
|
07250a221a | ||
|
|
517ae236b3 | ||
|
|
a70b5a2f6a | ||
|
|
6c7563bd15 | ||
|
|
4da3cfc787 | ||
|
|
ff43c7c9aa | ||
|
|
303983bd3e | ||
|
|
6526a0b0e3 | ||
|
|
395a9907d7 | ||
|
|
4e3c0b60c1 | ||
|
|
5f9e77a500 | ||
|
|
f0b0db9aaa | ||
|
|
dc6d9985d8 | ||
|
|
d34c2e8568 | ||
|
|
9239353239 | ||
|
|
fe0c5a4f1b | ||
|
|
4075cc6501 | ||
|
|
16dbe70014 | ||
|
|
17f7e2b168 | ||
|
|
838f7a5e14 | ||
|
|
e7fb276ee9 | ||
|
|
736f0a0839 | ||
|
|
727f3702d0 | ||
|
|
f9212d01b7 |
@@ -44,13 +44,16 @@ func main() {
|
||||
instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)")
|
||||
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
|
||||
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := visited(fs)
|
||||
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -60,6 +63,7 @@ func main() {
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend,
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
@@ -76,6 +80,8 @@ func visited(fs *flag.FlagSet) map[string]bool {
|
||||
name = "insecureListen"
|
||||
case "allowed-hosts":
|
||||
name = "allowedHosts"
|
||||
case "disable-exec-tools":
|
||||
name = "disableExecTools"
|
||||
}
|
||||
set[name] = true
|
||||
})
|
||||
@@ -100,11 +106,14 @@ func runConfig(args []string) int {
|
||||
instance := fs.String("instance", "", "")
|
||||
insecure := fs.Bool("insecure-listen", false, "")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "")
|
||||
execBackend := fs.String("exec-backend", "", "")
|
||||
fs.Parse(args[1:])
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-command updated: cc1ad77498...4f583ca3ee
Vendored
+1
-1
Submodule deps/agent-intent updated: 65aaebca1e...343b1e6d9a
Vendored
+1
-1
Submodule deps/agent-native updated: 3a110a728c...3b92cb63b4
Vendored
+1
-1
Submodule deps/agent-squad updated: 5a57636cc0...8bbf850fad
Vendored
+1
-1
Submodule deps/agent-system-core updated: 0b5c7747a3...a253a40b8e
+175
@@ -0,0 +1,175 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ab.go — 任务级 A/B 双跑 harness(Q3 WP5):同一批任务分别经两个入口
|
||||
// (direct 对话循环 / squad 编译图)执行,逐任务记录差异(不要求字节级
|
||||
// 相同),汇总为两份 EntryScore 交 DecideSwitch 裁决默认入口切换。
|
||||
// 本包保持纯机制(不依赖 runtime/squad);两侧 RunFunc 由调用方装配。
|
||||
|
||||
// ABTask 是对照批中的一个任务。
|
||||
type ABTask struct {
|
||||
ID string
|
||||
Goal string
|
||||
// Themes 期望产出要点(短词,非整句照抄):打分用 MissingThemes,
|
||||
// 差异记录用要点覆盖对比。
|
||||
Themes []string
|
||||
}
|
||||
|
||||
// ABOutcome 是一个入口对一个任务的执行结果(调用方 RunFunc 填写)。
|
||||
type ABOutcome struct {
|
||||
// Completed 任务是否正常收口(失败/取消 = false)。
|
||||
Completed bool
|
||||
// Summary 交付摘要(要点覆盖打分的对象;调用方可拼接工件正文)。
|
||||
Summary string
|
||||
// FirstToken 首字延迟(入口收到任务到首个可见输出)。
|
||||
FirstToken time.Duration
|
||||
// Err 失败原因(Completed=false 时填写)。
|
||||
Err string
|
||||
}
|
||||
|
||||
// RunFunc 是一个入口的执行侧(direct 或 squad 编译图)。
|
||||
type RunFunc func(ctx context.Context, task ABTask) (ABOutcome, error)
|
||||
|
||||
// ABTaskRecord 是一个任务的双跑对照记录(差异记录单位)。
|
||||
type ABTaskRecord struct {
|
||||
Task ABTask
|
||||
Direct ABOutcome
|
||||
Squad ABOutcome
|
||||
// Diffs 差异注记(状态 / 要点覆盖 / 首字延迟),空 = 两侧一致。
|
||||
Diffs []string
|
||||
// DirectPoints / SquadPoints 单任务得分(满分 100:完成 + 要点全覆盖;
|
||||
// 未完成 0 分;完成但缺要点按缺失比例扣)。
|
||||
DirectPoints int
|
||||
SquadPoints int
|
||||
}
|
||||
|
||||
// ABReport 是一批双跑的对照报告(切换裁决的证据形态)。
|
||||
type ABReport struct {
|
||||
Records []ABTaskRecord
|
||||
Direct EntryScore
|
||||
Squad EntryScore
|
||||
Decision SwitchDecision
|
||||
// Text 是可附在切换 PR / 任务卡上的短报告(Report(Decision) + 差异清单)。
|
||||
Text string
|
||||
}
|
||||
|
||||
// RunAB 双跑同一批任务并汇总对照报告。任一侧 RunFunc 返回 error 视为该侧
|
||||
// 该任务失败(记录进 outcome.Err),不中断整批(对照要的是全批差异面)。
|
||||
func RunAB(ctx context.Context, tasks []ABTask, direct, squad RunFunc) ABReport {
|
||||
rep := ABReport{}
|
||||
if len(tasks) == 0 {
|
||||
rep.Decision = DecideSwitch(nil, nil)
|
||||
rep.Text = Report(rep.Decision)
|
||||
return rep
|
||||
}
|
||||
var directFT, squadFT []time.Duration
|
||||
var directSum, squadSum int
|
||||
for _, task := range tasks {
|
||||
rec := ABTaskRecord{Task: task}
|
||||
rec.Direct = runOne(ctx, task, direct)
|
||||
rec.Squad = runOne(ctx, task, squad)
|
||||
rec.DirectPoints = scoreOutcome(rec.Direct, task.Themes)
|
||||
rec.SquadPoints = scoreOutcome(rec.Squad, task.Themes)
|
||||
rec.Diffs = diffRecord(rec)
|
||||
rep.Records = append(rep.Records, rec)
|
||||
directSum += rec.DirectPoints
|
||||
squadSum += rec.SquadPoints
|
||||
directFT = append(directFT, rec.Direct.FirstToken)
|
||||
squadFT = append(squadFT, rec.Squad.FirstToken)
|
||||
}
|
||||
rep.Direct = EntryScore{Name: "direct", Score: directSum / len(tasks), FirstTokenP50: p50(directFT)}
|
||||
rep.Squad = EntryScore{Name: "squad", Score: squadSum / len(tasks), FirstTokenP50: p50(squadFT)}
|
||||
rep.Decision = DecideSwitch(&rep.Direct, &rep.Squad)
|
||||
rep.Text = renderReport(rep)
|
||||
return rep
|
||||
}
|
||||
|
||||
func runOne(ctx context.Context, task ABTask, fn RunFunc) ABOutcome {
|
||||
if fn == nil {
|
||||
return ABOutcome{Err: "入口未装配"}
|
||||
}
|
||||
out, err := fn(ctx, task)
|
||||
if err != nil {
|
||||
out.Completed = false
|
||||
if out.Err == "" {
|
||||
out.Err = err.Error()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scoreOutcome 单任务打分:未完成 0;完成 = 100 - 缺失要点比例 × 100。
|
||||
func scoreOutcome(out ABOutcome, themes []string) int {
|
||||
if !out.Completed {
|
||||
return 0
|
||||
}
|
||||
if len(themes) == 0 {
|
||||
return 100
|
||||
}
|
||||
missing := MissingThemes(out.Summary, themes)
|
||||
return 100 - len(missing)*100/len(themes)
|
||||
}
|
||||
|
||||
// diffRecord 逐任务差异注记(状态 / 要点 / 首字延迟;不要求字节级相同)。
|
||||
func diffRecord(rec ABTaskRecord) []string {
|
||||
var diffs []string
|
||||
if rec.Direct.Completed != rec.Squad.Completed {
|
||||
diffs = append(diffs, fmt.Sprintf("状态:direct=%v squad=%v(%s / %s)",
|
||||
rec.Direct.Completed, rec.Squad.Completed, rec.Direct.Err, rec.Squad.Err))
|
||||
}
|
||||
if len(rec.Task.Themes) > 0 {
|
||||
dm := MissingThemes(rec.Direct.Summary, rec.Task.Themes)
|
||||
sm := MissingThemes(rec.Squad.Summary, rec.Task.Themes)
|
||||
if strings.Join(dm, ",") != strings.Join(sm, ",") {
|
||||
diffs = append(diffs, fmt.Sprintf("要点缺失:direct=%v squad=%v", dm, sm))
|
||||
}
|
||||
}
|
||||
gap := rec.Squad.FirstToken - rec.Direct.FirstToken
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap > time.Second {
|
||||
diffs = append(diffs, fmt.Sprintf("首字延迟差:%s(direct=%s squad=%s)",
|
||||
gap, rec.Direct.FirstToken, rec.Squad.FirstToken))
|
||||
}
|
||||
if rec.Direct.Completed && rec.Squad.Completed &&
|
||||
strings.TrimSpace(rec.Direct.Summary) != strings.TrimSpace(rec.Squad.Summary) {
|
||||
diffs = append(diffs, "摘要文本不同(记录在案,不要求字节级相同)")
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
func p50(ds []time.Duration) time.Duration {
|
||||
if len(ds) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := append([]time.Duration(nil), ds...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
return sorted[len(sorted)/2]
|
||||
}
|
||||
|
||||
// renderReport 报告全文:裁决 + 分数 + 逐任务差异清单。
|
||||
func renderReport(rep ABReport) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(Report(rep.Decision))
|
||||
fmt.Fprintf(&b, "\n分数:direct=%d squad=%d;首字P50:direct=%s squad=%s\n",
|
||||
rep.Direct.Score, rep.Squad.Score, rep.Direct.FirstTokenP50, rep.Squad.FirstTokenP50)
|
||||
for _, rec := range rep.Records {
|
||||
fmt.Fprintf(&b, "- %s:direct %d 分 / squad %d 分", rec.Task.ID, rec.DirectPoints, rec.SquadPoints)
|
||||
if len(rec.Diffs) == 0 {
|
||||
b.WriteString("(无差异注记)")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, d := range rec.Diffs {
|
||||
fmt.Fprintf(&b, " - %s\n", d)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ab_test.go — Q3 WP5:A/B 双跑 harness 机制面(差异记录 / 打分 / P50 /
|
||||
// DecideSwitch 联动)。真实入口的集成对照在 internal/wfcompile 的
|
||||
// demo.brief 双跑测试。
|
||||
|
||||
func stubRun(completed bool, summary string, ft time.Duration, errText string) RunFunc {
|
||||
return func(context.Context, ABTask) (ABOutcome, error) {
|
||||
return ABOutcome{Completed: completed, Summary: summary, FirstToken: ft, Err: errText}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABRecordsDiffsAndScores(t *testing.T) {
|
||||
tasks := []ABTask{
|
||||
{ID: "t1", Goal: "写简报", Themes: []string{"太空电梯", "核对标记"}},
|
||||
{ID: "t2", Goal: "查资料", Themes: []string{"三来源"}},
|
||||
}
|
||||
direct := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||||
switch task.ID {
|
||||
case "t1":
|
||||
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报,核对标记:P4", FirstToken: 100 * time.Millisecond}, nil
|
||||
default:
|
||||
return ABOutcome{Completed: true, Summary: "对照完成,三来源齐", FirstToken: 200 * time.Millisecond}, nil
|
||||
}
|
||||
}
|
||||
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||||
switch task.ID {
|
||||
case "t1":
|
||||
// 完成但缺一个要点 → 扣分 + 差异注记。
|
||||
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报", FirstToken: 100 * time.Millisecond}, nil
|
||||
default:
|
||||
return ABOutcome{Completed: false, Err: "编译图步骤失败", FirstToken: 0}, nil
|
||||
}
|
||||
}
|
||||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||||
if len(rep.Records) != 2 {
|
||||
t.Fatalf("records = %d", len(rep.Records))
|
||||
}
|
||||
r1 := rep.Records[0]
|
||||
if r1.DirectPoints != 100 || r1.SquadPoints != 50 {
|
||||
t.Fatalf("t1 points = %d/%d", r1.DirectPoints, r1.SquadPoints)
|
||||
}
|
||||
if !strings.Contains(strings.Join(r1.Diffs, ";"), "要点缺失") {
|
||||
t.Fatalf("t1 diffs = %v", r1.Diffs)
|
||||
}
|
||||
r2 := rep.Records[1]
|
||||
if r2.SquadPoints != 0 || !strings.Contains(strings.Join(r2.Diffs, ";"), "状态") {
|
||||
t.Fatalf("t2 = %d %v", r2.SquadPoints, r2.Diffs)
|
||||
}
|
||||
// 分数汇总:direct 100,squad (50+0)/2=25 → 不达标,保持 direct。
|
||||
if rep.Direct.Score != 100 || rep.Squad.Score != 25 {
|
||||
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
|
||||
}
|
||||
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "低于") {
|
||||
t.Fatalf("decision = %+v", rep.Decision)
|
||||
}
|
||||
if !strings.Contains(rep.Text, "保持 direct") {
|
||||
t.Fatalf("report = %s", rep.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABSwitchMetWhenEqual(t *testing.T) {
|
||||
tasks := []ABTask{{ID: "t1", Goal: "g", Themes: []string{"要点"}}}
|
||||
fn := stubRun(true, "覆盖要点的产出", 50*time.Millisecond, "")
|
||||
rep := RunAB(context.Background(), tasks, fn, fn)
|
||||
if rep.Direct.Score != 100 || rep.Squad.Score != 100 {
|
||||
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
|
||||
}
|
||||
if !rep.Decision.Met {
|
||||
t.Fatalf("同分同延迟应达标:%+v", rep.Decision)
|
||||
}
|
||||
if len(rep.Records[0].Diffs) != 0 {
|
||||
t.Fatalf("同产出应无差异注记:%v", rep.Records[0].Diffs)
|
||||
}
|
||||
if rep.Direct.FirstTokenP50 != 50*time.Millisecond {
|
||||
t.Fatalf("p50 = %s", rep.Direct.FirstTokenP50)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABLatencyGate(t *testing.T) {
|
||||
tasks := []ABTask{{ID: "t1", Goal: "g"}}
|
||||
direct := stubRun(true, "ok", 100*time.Millisecond, "")
|
||||
squad := stubRun(true, "ok", 4*time.Second, "") // 额外首字 > 3s
|
||||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||||
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "超过 3 秒") {
|
||||
t.Fatalf("decision = %+v", rep.Decision)
|
||||
}
|
||||
if !strings.Contains(strings.Join(rep.Records[0].Diffs, ";"), "首字延迟差") {
|
||||
t.Fatalf("diffs = %v", rep.Records[0].Diffs)
|
||||
}
|
||||
}
|
||||
|
||||
// RunFunc 返回 error:记为该侧失败,不中断整批。
|
||||
func TestRunABToleratesRunFuncError(t *testing.T) {
|
||||
tasks := []ABTask{{ID: "t1"}, {ID: "t2"}}
|
||||
direct := stubRun(true, "ok", 0, "")
|
||||
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||||
if task.ID == "t1" {
|
||||
return ABOutcome{}, context.DeadlineExceeded
|
||||
}
|
||||
return ABOutcome{Completed: true, Summary: "ok"}, nil
|
||||
}
|
||||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||||
if rep.Records[0].SquadPoints != 0 || rep.Records[0].Squad.Err == "" {
|
||||
t.Fatalf("t1 = %+v", rep.Records[0].Squad)
|
||||
}
|
||||
if rep.Records[1].SquadPoints != 100 {
|
||||
t.Fatalf("t2 应不受 t1 失败影响:%+v", rep.Records[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABEmptyBatch(t *testing.T) {
|
||||
rep := RunAB(context.Background(), nil, nil, nil)
|
||||
if rep.Decision.Met {
|
||||
t.Fatal("空批不得达标(无实测分数)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//go:build live
|
||||
|
||||
package live
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// upload 上传一个材料文件,返回 artifact id。
|
||||
func (c *client) upload(t *testing.T, projectID, name string, content []byte) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatalf("multipart field: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(content); err != nil {
|
||||
t.Fatalf("multipart write: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
req, err := http.NewRequest("POST", c.base+"/api/projects/"+projectID+"/uploads", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("upload request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
t.Fatalf("upload %s: %s: %s", name, resp.Status, truncate(string(raw), 300))
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"artifactId"`
|
||||
LegacyID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("upload response: %v %s", err, truncate(string(raw), 300))
|
||||
}
|
||||
if out.ID == "" {
|
||||
out.ID = out.LegacyID
|
||||
}
|
||||
if out.ID == "" {
|
||||
t.Fatalf("upload response missing artifact id: %s", truncate(string(raw), 300))
|
||||
}
|
||||
return out.ID
|
||||
}
|
||||
|
||||
// runTurn 发一轮消息并等到 run 终态;期间自动应答 HITL 卡。
|
||||
func runTurn(t *testing.T, c *client, projectID, convID, text string, attachmentIDs []string, turnTimeout time.Duration) turnResult {
|
||||
t.Helper()
|
||||
started := time.Now()
|
||||
var out struct {
|
||||
Message message `json:"message"`
|
||||
Run run `json:"run"`
|
||||
}
|
||||
body := map[string]any{"text": text, "requestId": fmt.Sprintf("eval-%d", time.Now().UnixNano())}
|
||||
if len(attachmentIDs) > 0 {
|
||||
body["attachmentIds"] = attachmentIDs
|
||||
}
|
||||
if err := c.do("POST", "/api/conversations/"+convID+"/messages", body, &out); err != nil {
|
||||
t.Fatalf("post message: %v", err)
|
||||
}
|
||||
runID := out.Run.ID
|
||||
deadline := time.Now().Add(turnTimeout)
|
||||
var cur run
|
||||
for {
|
||||
var resp struct {
|
||||
Run run `json:"run"`
|
||||
}
|
||||
if err := c.do("GET", "/api/runs/"+runID, nil, &resp); err != nil {
|
||||
t.Fatalf("get run: %v", err)
|
||||
}
|
||||
cur = resp.Run
|
||||
if isTerminal(cur.Status) {
|
||||
break
|
||||
}
|
||||
autoResolveHITL(t, c, convID)
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("run %s timed out after %s (status=%s)", runID, turnTimeout, cur.Status)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
detail := struct {
|
||||
Messages []message `json:"messages"`
|
||||
ActiveHitl []hitlCard `json:"activeHitl"`
|
||||
}{}
|
||||
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
|
||||
t.Fatalf("conversation detail: %v", err)
|
||||
}
|
||||
var assistant *message
|
||||
for i := range detail.Messages {
|
||||
m := detail.Messages[i]
|
||||
if m.RunID == runID && m.Role == "assistant" && !m.Excluded {
|
||||
assistant = &detail.Messages[i]
|
||||
}
|
||||
}
|
||||
tr := turnResult{User: text, RunID: runID, Status: cur.Status, DurationMs: time.Since(started).Milliseconds()}
|
||||
if cur.Usage != nil {
|
||||
tr.Usage = &cur
|
||||
}
|
||||
if assistant == nil {
|
||||
tr.Issues = append(tr.Issues, "没有找到本回合的助手消息")
|
||||
return tr
|
||||
}
|
||||
collectMetrics(&tr, assistant)
|
||||
// 工件清单:本 run 产出的。
|
||||
var arts []artifactMeta
|
||||
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err == nil {
|
||||
for _, a := range arts {
|
||||
if a.SourceRun == runID {
|
||||
tr.Artifacts = append(tr.Artifacts, a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.Status != "completed" {
|
||||
tr.Issues = append(tr.Issues, "run 终态="+cur.Status)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func isTerminal(status string) bool {
|
||||
switch status {
|
||||
case "completed", "failed", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoResolveHITL 无人值守:有活动确认卡时选第一个选项(没有选项给自由文本同意)。
|
||||
func autoResolveHITL(t *testing.T, c *client, convID string) {
|
||||
t.Helper()
|
||||
detail := struct {
|
||||
ActiveHitl []hitlCard `json:"activeHitl"`
|
||||
}{}
|
||||
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
|
||||
return
|
||||
}
|
||||
for _, card := range detail.ActiveHitl {
|
||||
resp := map[string]string{}
|
||||
if len(card.Choices) > 0 {
|
||||
resp["choiceId"] = card.Choices[0].ID
|
||||
} else if card.AllowFreeInput {
|
||||
resp["freeText"] = "同意继续(评测自动应答)"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
_ = c.do("POST", "/api/hitl/"+card.ID+"/resolve", resp, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func collectMetrics(tr *turnResult, m *message) {
|
||||
for _, p := range m.Parts {
|
||||
switch p.Type {
|
||||
case "text":
|
||||
if !p.Process && strings.TrimSpace(p.Text) != "" {
|
||||
tr.FinalText = p.Text
|
||||
}
|
||||
case "tool_call":
|
||||
if p.ToolCall == nil {
|
||||
continue
|
||||
}
|
||||
tr.Metrics.ToolCalls++
|
||||
args := p.ToolCall.Arguments
|
||||
switch p.ToolCall.Name {
|
||||
case "update_plan", "update_todos":
|
||||
tr.Metrics.PlanUpdates++
|
||||
case "run_python":
|
||||
if strings.Contains(args, "new_document(") || strings.Contains(args, "new_presentation(") || strings.Contains(args, "new_workbook(") {
|
||||
tr.Metrics.FullRegens++
|
||||
}
|
||||
}
|
||||
if strings.Contains(args, "dir(") || strings.Contains(args, "inspect.signature") || strings.Contains(args, "help(") {
|
||||
tr.Metrics.APIDiscovery++
|
||||
}
|
||||
if strings.Contains(args, "inputs/") {
|
||||
tr.Metrics.ReadsOriginal++
|
||||
}
|
||||
case "tool_result":
|
||||
if p.ToolResult == nil {
|
||||
continue
|
||||
}
|
||||
s := p.ToolResult.Summary
|
||||
if p.ToolResult.Status == "error" || strings.HasPrefix(s, "exit=1") || strings.Contains(s, "Traceback") || strings.Contains(s, "ModuleNotFoundError") {
|
||||
tr.Metrics.ToolFailures++
|
||||
}
|
||||
case "check":
|
||||
if p.Check == nil {
|
||||
continue
|
||||
}
|
||||
tr.Metrics.VerifierChecks++
|
||||
tr.Checks = append(tr.Checks, checkRecord{Passed: p.Check.Passed, Reason: p.Check.Reason})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastArtifactTurn(tr taskResult) *turnResult {
|
||||
for i := len(tr.Turns) - 1; i >= 0; i-- {
|
||||
if len(tr.Turns[i].Artifacts) > 0 {
|
||||
return &tr.Turns[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadArtifactText(t *testing.T, c *client, projectID, name string) string {
|
||||
t.Helper()
|
||||
var arts []artifactMeta
|
||||
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range arts {
|
||||
if a.Name != name {
|
||||
continue
|
||||
}
|
||||
req, _ := http.NewRequest("GET", c.base+"/api/artifacts/"+a.ID+"/content", nil)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if strings.HasSuffix(strings.ToLower(a.Name), ".docx") {
|
||||
return docxTextIfDocx(t, a.Name, raw)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- 文本抽取与事实核查 ----
|
||||
|
||||
// docxTextIfDocx:docx 抽纯文本(标题行带 # 前缀);其他类型原样返回。
|
||||
func docxTextIfDocx(t *testing.T, name string, raw []byte) string {
|
||||
t.Helper()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".docx") {
|
||||
return string(raw)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rc.Close()
|
||||
dec := xml.NewDecoder(rc)
|
||||
var b strings.Builder
|
||||
var para strings.Builder
|
||||
style, inPara := "", false
|
||||
flush := func() {
|
||||
text := strings.TrimSpace(para.String())
|
||||
if text != "" {
|
||||
if lvl := docxHeadingLevel(style); lvl > 0 {
|
||||
b.WriteString(strings.Repeat("#", lvl) + " ")
|
||||
}
|
||||
b.WriteString(text + "\n")
|
||||
}
|
||||
para.Reset()
|
||||
style = ""
|
||||
}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch el.Name.Local {
|
||||
case "p":
|
||||
inPara, style = true, ""
|
||||
para.Reset()
|
||||
case "pStyle":
|
||||
if inPara {
|
||||
for _, at := range el.Attr {
|
||||
if at.Name.Local == "val" {
|
||||
style = at.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if el.Name.Local == "p" {
|
||||
inPara = false
|
||||
flush()
|
||||
}
|
||||
case xml.CharData:
|
||||
if inPara {
|
||||
para.Write(bytes.TrimSpace(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return b.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func docxHeadingLevel(style string) int {
|
||||
switch {
|
||||
case style == "Title":
|
||||
return 1
|
||||
case style == "Subtitle":
|
||||
return 2
|
||||
case strings.HasPrefix(style, "Heading"):
|
||||
n := 0
|
||||
for _, ch := range style[len("Heading"):] {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if n >= 1 && n <= 9 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var (
|
||||
pctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
|
||||
dateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日`)
|
||||
bigNumRe = regexp.MustCompile(`\d{4,}`)
|
||||
claimRe = regexp.MustCompile(`(?:^|\n|\s)([一二三四五六七八九十]{1,2}、[^\s,。;|]{2,24})`)
|
||||
)
|
||||
|
||||
// unverifiedFacts 抽取交付物里的百分比/日期/大数字,逐个回材料原文找出处。
|
||||
// 找不到的列为未核实事实(模型编造或来自模型自带的「常识」)。
|
||||
func unverifiedFacts(artifactText, materialText string) []string {
|
||||
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
check := func(tok string) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" || seen[tok] {
|
||||
return
|
||||
}
|
||||
seen[tok] = true
|
||||
if !strings.Contains(materialText, tok) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
for _, m := range pctRe.FindAllString(artifactText, -1) {
|
||||
check(m)
|
||||
}
|
||||
for _, m := range dateRe.FindAllString(artifactText, -1) {
|
||||
check(m)
|
||||
}
|
||||
for _, m := range bigNumRe.FindAllString(artifactText, -1) {
|
||||
if len(m) >= 5 { // 4 位数大量是年份/编号,只报 5 位以上
|
||||
check(m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。
|
||||
func finalVsArtifact(turn *turnResult, artifactText string) []string {
|
||||
if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" {
|
||||
return nil
|
||||
}
|
||||
final := turn.FinalText
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range claimRe.FindAllStringSubmatch(final, -1) {
|
||||
claim := strings.TrimSpace(m[1])
|
||||
if claim == "" || seen[claim] {
|
||||
continue
|
||||
}
|
||||
seen[claim] = true
|
||||
if !strings.Contains(artifactText, claim) {
|
||||
out = append(out, claim)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 报告 ----
|
||||
|
||||
func writeReport(t *testing.T, rep *report) {
|
||||
t.Helper()
|
||||
outPath := os.Getenv("AGENT_EVAL_OUT")
|
||||
if outPath == "" {
|
||||
outPath = filepath.Join("eval-live", "report-"+time.Now().Format("0102-150405")+".json")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir report dir: %v", err)
|
||||
}
|
||||
raw, _ := json.MarshalIndent(rep, "", " ")
|
||||
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n[live-eval] report: %s\n%s\n", outPath, renderMarkdown(rep))
|
||||
}
|
||||
|
||||
func renderMarkdown(rep *report) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Live eval — %s\n\n- target: %s\n- model: %s\n- generated: %s\n\n", rep.GeneratedAt, rep.Target, rep.Model, rep.GeneratedAt)
|
||||
for _, tk := range rep.Tasks {
|
||||
fmt.Fprintf(&b, "## %s\n\n| turn | status | tools | fails | regens | api探查 | 读原件 | 计划 | 检查 | 工件 | 时长s |\n|---|---|---|---|---|---|---|---|---|---|---|\n", tk.ID)
|
||||
for _, turn := range tk.Turns {
|
||||
fmt.Fprintf(&b, "| %s | %s | %d | %d | %d | %d | %d | %d | %d | %d | %.0f |\n",
|
||||
truncate(turn.User, 18), turn.Status, turn.Metrics.ToolCalls, turn.Metrics.ToolFailures,
|
||||
turn.Metrics.FullRegens, turn.Metrics.APIDiscovery, turn.Metrics.ReadsOriginal,
|
||||
turn.Metrics.PlanUpdates, turn.Metrics.VerifierChecks, len(turn.Artifacts),
|
||||
float64(turn.DurationMs)/1000)
|
||||
}
|
||||
if len(tk.UnverifiedFacts) > 0 {
|
||||
fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、"))
|
||||
}
|
||||
if len(tk.FinalVsArtifact) > 0 {
|
||||
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
|
||||
}
|
||||
fmt.Fprint(&b, "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//go:build live
|
||||
|
||||
// Package live 是真实后端 + 真实模型的任务级评测(Phase 0 基线)。
|
||||
//
|
||||
// 用法(对准一个已配置好模型的运行实例):
|
||||
//
|
||||
// AGENT_EVAL_BASE=http://127.0.0.1:18799 \
|
||||
// AGENT_EVAL_MATERIALS=~/.agent-system/eval/materials \
|
||||
// AGENT_EVAL_OUT=/tmp/before.json \
|
||||
// go test ./eval/live/ -tags live -run TestLiveSuite -v -timeout 90m
|
||||
//
|
||||
// 评测器只用公开 HTTP 面(上传→建会话→发消息→轮询),指标从消息 parts、
|
||||
// run usage 与工件内容计算;不触碰目标实例数据库。HITL 卡自动选第一个选项,
|
||||
// 保证无人值守跑完。产出 JSON + Markdown 报告;任务集见 tasks.json。
|
||||
// 真实材料不进仓:materials 目录按 eval/README 的口径放本机。
|
||||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type task struct {
|
||||
ID string `json:"id"`
|
||||
Material string `json:"material,omitempty"`
|
||||
Turns []string `json:"turns"`
|
||||
}
|
||||
|
||||
type part struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Process bool `json:"process"`
|
||||
ToolCall *struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
Duration float64 `json:"durationMs"`
|
||||
} `json:"toolCall"`
|
||||
ToolResult *struct {
|
||||
CallID string `json:"callId"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"toolResult"`
|
||||
Check *struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"check"`
|
||||
Artifact *struct {
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"artifact"`
|
||||
}
|
||||
|
||||
type message struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"runId"`
|
||||
Role string `json:"role"`
|
||||
Parts []part `json:"parts"`
|
||||
Excluded bool `json:"excluded"`
|
||||
}
|
||||
|
||||
type run struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
DurationMs int `json:"durationMs"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
} `json:"usage"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
|
||||
type artifactMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
MIME string `json:"mime"`
|
||||
SourceRun string `json:"sourceRun"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type hitlCard struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Choices []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
} `json:"choices"`
|
||||
AllowFreeInput bool `json:"allowFreeInput"`
|
||||
}
|
||||
|
||||
type client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
t *testing.T
|
||||
model string
|
||||
}
|
||||
|
||||
func (c *client) do(method, path string, body any, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.base+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, truncate(string(raw), 300))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
func TestLiveSuite(t *testing.T) {
|
||||
base := strings.TrimSpace(os.Getenv("AGENT_EVAL_BASE"))
|
||||
if base == "" {
|
||||
t.Skip("AGENT_EVAL_BASE not set; live eval requires a configured backend instance")
|
||||
}
|
||||
materialsDir := os.Getenv("AGENT_EVAL_MATERIALS")
|
||||
if materialsDir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
materialsDir = filepath.Join(home, ".agent-system", "eval", "materials")
|
||||
}
|
||||
if _, err := os.Stat(materialsDir); err != nil {
|
||||
t.Skipf("materials dir not available: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile("tasks.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read tasks.json: %v", err)
|
||||
}
|
||||
var tasks []task
|
||||
if err := json.Unmarshal(raw, &tasks); err != nil {
|
||||
t.Fatalf("parse tasks.json: %v", err)
|
||||
}
|
||||
if filter := os.Getenv("AGENT_EVAL_TASKS"); filter != "" {
|
||||
want := map[string]bool{}
|
||||
for _, id := range strings.Split(filter, ",") {
|
||||
want[strings.TrimSpace(id)] = true
|
||||
}
|
||||
var kept []task
|
||||
for _, tk := range tasks {
|
||||
if want[tk.ID] {
|
||||
kept = append(kept, tk)
|
||||
}
|
||||
}
|
||||
tasks = kept
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("no tasks selected")
|
||||
}
|
||||
turnTimeout := 20 * time.Minute
|
||||
if v := os.Getenv("AGENT_EVAL_TURN_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
turnTimeout = d
|
||||
}
|
||||
}
|
||||
c := &client{base: base, http: &http.Client{Timeout: 120 * time.Second}, t: t}
|
||||
// /api/settings 的形状是 {defaults, project, schema, user};模型播在 user 作用域。
|
||||
var settings struct {
|
||||
User struct {
|
||||
Models struct {
|
||||
Chat struct {
|
||||
Model string `json:"model"`
|
||||
} `json:"chat"`
|
||||
} `json:"models"`
|
||||
} `json:"user"`
|
||||
}
|
||||
_ = c.do("GET", "/api/settings", nil, &settings)
|
||||
c.model = settings.User.Models.Chat.Model
|
||||
|
||||
proj := struct {
|
||||
ID string `json:"id"`
|
||||
}{}
|
||||
if err := c.do("POST", "/api/projects", map[string]string{"name": "eval-" + time.Now().Format("0102-150405")}, &proj); err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
rep := report{Target: base, Model: c.model, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
for _, tk := range tasks {
|
||||
rep.Tasks = append(rep.Tasks, runTask(t, c, proj.ID, tk, materialsDir, turnTimeout))
|
||||
}
|
||||
writeReport(t, &rep)
|
||||
}
|
||||
|
||||
type turnResult struct {
|
||||
User string `json:"user"`
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
FinalText string `json:"finalText,omitempty"`
|
||||
Usage *run `json:"usage,omitempty"`
|
||||
Metrics turnMetrics `json:"metrics"`
|
||||
Checks []checkRecord `json:"checks,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
Issues []string `json:"issues,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type turnMetrics struct {
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
ToolFailures int `json:"toolFailures"`
|
||||
FullRegens int `json:"fullRegens"`
|
||||
APIDiscovery int `json:"apiDiscovery"`
|
||||
ReadsOriginal int `json:"readsOriginal"`
|
||||
PlanUpdates int `json:"planUpdates"`
|
||||
VerifierChecks int `json:"verifierChecks"`
|
||||
}
|
||||
|
||||
type checkRecord struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type taskResult struct {
|
||||
ID string `json:"id"`
|
||||
Turns []turnResult `json:"turns"`
|
||||
Total turnMetrics `json:"total"`
|
||||
UnverifiedFacts []string `json:"unverifiedFacts,omitempty"`
|
||||
FinalVsArtifact []string `json:"finalVsArtifact,omitempty"`
|
||||
}
|
||||
|
||||
type report struct {
|
||||
Target string `json:"target"`
|
||||
Model string `json:"model,omitempty"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Tasks []taskResult `json:"tasks"`
|
||||
}
|
||||
|
||||
func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir string, turnTimeout time.Duration) taskResult {
|
||||
t.Helper()
|
||||
conv := struct {
|
||||
ID string `json:"id"`
|
||||
}{}
|
||||
if err := c.do("POST", "/api/projects/"+projectID+"/conversations", map[string]string{"title": "eval-" + tk.ID}, &conv); err != nil {
|
||||
t.Fatalf("[%s] create conversation: %v", tk.ID, err)
|
||||
}
|
||||
var attachmentIDs []string
|
||||
materialText := ""
|
||||
if tk.Material != "" {
|
||||
path := filepath.Join(materialsDir, tk.Material)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] open material: %v", tk.ID, err)
|
||||
}
|
||||
up := c.upload(t, projectID, tk.Material, raw)
|
||||
attachmentIDs = append(attachmentIDs, up)
|
||||
materialText = docxTextIfDocx(t, tk.Material, raw)
|
||||
}
|
||||
tr := taskResult{ID: tk.ID}
|
||||
for i, turnText := range tk.Turns {
|
||||
tr.Turns = append(tr.Turns, runTurn(t, c, projectID, conv.ID, turnText, attachmentIDs, turnTimeout))
|
||||
attachmentIDs = nil // 材料只在第一轮附带;后续轮考验跨轮上下文
|
||||
_ = i
|
||||
}
|
||||
// 汇总
|
||||
for _, turn := range tr.Turns {
|
||||
tr.Total.ToolCalls += turn.Metrics.ToolCalls
|
||||
tr.Total.ToolFailures += turn.Metrics.ToolFailures
|
||||
tr.Total.FullRegens += turn.Metrics.FullRegens
|
||||
tr.Total.APIDiscovery += turn.Metrics.APIDiscovery
|
||||
tr.Total.ReadsOriginal += turn.Metrics.ReadsOriginal
|
||||
tr.Total.PlanUpdates += turn.Metrics.PlanUpdates
|
||||
tr.Total.VerifierChecks += turn.Metrics.VerifierChecks
|
||||
}
|
||||
// 事实核查:最后一个带工件的回合 vs 材料
|
||||
if materialText != "" {
|
||||
last := lastArtifactTurn(tr)
|
||||
if last != nil && len(last.Artifacts) > 0 {
|
||||
artText := ""
|
||||
for _, name := range last.Artifacts {
|
||||
artText += downloadArtifactText(t, c, projectID, name)
|
||||
}
|
||||
tr.UnverifiedFacts = unverifiedFacts(artText, materialText)
|
||||
tr.FinalVsArtifact = finalVsArtifact(last, artText)
|
||||
}
|
||||
}
|
||||
return tr
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "minutes-basic",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": ["帮我总结一份会议纪要"]
|
||||
},
|
||||
{
|
||||
"id": "plan-followup",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": [
|
||||
"帮我总结一份会议纪要",
|
||||
"根据这个纪要,制定一个方案",
|
||||
"方案太简要了,给我一个详细的方案和计划"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qa-citation",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": ["这次会议最主要的事情是什么"]
|
||||
},
|
||||
{
|
||||
"id": "minutes-pm-tool",
|
||||
"material": "meeting-pm-tool-asr.docx",
|
||||
"turns": ["生成会议纪要"]
|
||||
}
|
||||
]
|
||||
+128
-16
@@ -36,6 +36,7 @@ import (
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/hostapi"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentsem"
|
||||
"agent-system-backend/internal/pgbus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
@@ -65,6 +66,19 @@ type Options struct {
|
||||
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
|
||||
InsecureListen bool
|
||||
AllowedHosts []string
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不自动写检索索引(发布决定
|
||||
// 移交下游命令层);零值=false=现状。由 link 层自 extension.Options 透传。
|
||||
DisableUploadIndexing bool
|
||||
// DisableConvCardMirror EXT-12:true 时会话卡不镜像进项目公共流(不发布
|
||||
// 即不持久,重放不含会话卡);零值=false=现状全量镜像。
|
||||
DisableConvCardMirror bool
|
||||
// DisableExecTools 启动级硬关闭执行原语(run_shell / run_python /
|
||||
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
|
||||
// 零值=false=现状。
|
||||
DisableExecTools bool
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
|
||||
// 硬关闭 DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -83,7 +97,6 @@ func Run(opt Options) error {
|
||||
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
||||
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
||||
}
|
||||
traceFn := telemetry.StartFunc(logger)
|
||||
var st *store.Store
|
||||
var err error
|
||||
if opt.StorageDSN != "" {
|
||||
@@ -112,7 +125,19 @@ func Run(opt Options) error {
|
||||
}
|
||||
// 执行原语(agent-native 交付):shell/python 在会话工作区执行 + 产物登记桥,
|
||||
// runtime.exec=false 可整体禁用(fail-closed)。生成文档/表格/演示稿的方法
|
||||
// 在提示词库 base.deliverables,不在系统工具里。
|
||||
// 在提示词库 base.deliverables,不在系统工具里。启动参数 DisableExecTools
|
||||
// 为不受用户设置影响的硬关闭(多用户部署的默认安全档)。
|
||||
tools.SetExecDisabled(opt.DisableExecTools)
|
||||
if opt.DisableExecTools {
|
||||
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
|
||||
}
|
||||
// EXT-16:执行后端选择(direct|sandbox);未知名字启动即失败(fail-closed)。
|
||||
if err := tools.SetExecBackend(opt.ExecBackend); err != nil {
|
||||
return fmt.Errorf("exec backend: %w", err)
|
||||
}
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
|
||||
}
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
@@ -123,6 +148,10 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("code orchestration tool: %w", err)
|
||||
}
|
||||
setSvc := settings.NewService(st)
|
||||
effBoot := setSvc.Effective(store.OwnerDefault, "")
|
||||
traceFn := telemetry.StartFunc(logger,
|
||||
telemetry.WithServiceName(effBoot.GetString("telemetry.serviceName", "agent-system-backend")),
|
||||
telemetry.WithEndpoint(effBoot.GetString("telemetry.otlpEndpoint", "")))
|
||||
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
|
||||
// 点路径键展开必须在读 tools.tavily.apiKeys 之前,否则启动仍注册旧池。
|
||||
norm, err := setSvc.NormalizeStored()
|
||||
@@ -147,18 +176,22 @@ func Run(opt Options) error {
|
||||
rag.SecretLookup = func(service, fallback string) string {
|
||||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||||
}
|
||||
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
|
||||
eff := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyRaw := eff.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyRaw = active
|
||||
}
|
||||
if keys := splitKeyPool(tavilyRaw); len(keys) > 0 {
|
||||
provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys)
|
||||
if err := tools.RegisterSearchTools(reg, provider); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
// 网络搜索(Tavily):始终注册,密钥按调用时读取(设置页/svc 播种改了
|
||||
// 即刻生效——此前启动时快照密钥,播种后必须重启工具才注册)。
|
||||
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
|
||||
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
|
||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyNow = active
|
||||
}
|
||||
log.Printf("tools: tavily_search/web_search registered (%d key(s))", len(keys))
|
||||
keys := splitKeyPool(tavilyNow)
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
}
|
||||
promptLib, err := prompts.DefaultLibrary()
|
||||
if err != nil {
|
||||
@@ -255,6 +288,9 @@ func Run(opt Options) error {
|
||||
}
|
||||
}
|
||||
cfg.Trace = traceFn
|
||||
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
|
||||
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
|
||||
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
|
||||
if host, ok := rc.(interface {
|
||||
OpenCheckpoints() (*checkpoint.Log, error)
|
||||
CheckpointAnchor() string
|
||||
@@ -294,6 +330,8 @@ func Run(opt Options) error {
|
||||
if err := mgr.ReconcileInterrupted(); err != nil {
|
||||
return fmt.Errorf("reconcile interrupted runs: %w", err)
|
||||
}
|
||||
// 后台子代理通知重启重投(N2 WP5):未投递结果装回队列并按空闲语义投递。
|
||||
mgr.LoadPendingNotices()
|
||||
mgr.SetSquadRunner(&runtime.SquadRunner{})
|
||||
|
||||
// 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。
|
||||
@@ -327,14 +365,30 @@ func Run(opt Options) error {
|
||||
|
||||
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
|
||||
pipe := rag.NewPipeline(st, setSvc, nil, nil)
|
||||
pipe.SetEmbedder(rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
|
||||
embedder := rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
|
||||
return setSvc.Effective(store.OwnerDefault, "").GetString("models.embedding.provider", "")
|
||||
}})
|
||||
}}
|
||||
pipe.SetEmbedder(embedder)
|
||||
pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute))
|
||||
mgr.SetRetriever(pipe)
|
||||
mgr.SetIndexer(pipe)
|
||||
|
||||
// 意图慢通道(I2):语义向量裁决 + 轻量模型命令生成。
|
||||
// 两个开关都默认关——此时识别行为与 I1 完全一致(fail-closed 分层);
|
||||
// 开启语义需已配置 embedding 端点,开启生成需可用的轻量/主模型档。
|
||||
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentSemantic", false) {
|
||||
gate.SetSemantic(intentsem.EmbeddingScorer{Provider: embedder}, 0)
|
||||
}
|
||||
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentGenerator", false) {
|
||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||
light := runtime.ResolveLightweightModel(core.AgentProfile{}, effNow)
|
||||
client := &agentllm.Client{BaseURL: light.Base, APIKey: light.Key, HTTPClient: &http.Client{}}
|
||||
gate.SetGenerator(intentsem.LightGenerator{Stream: client.Stream, Model: light.Model})
|
||||
}
|
||||
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
|
||||
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
|
||||
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
|
||||
mgr.ResumeInterruptedChildren()
|
||||
|
||||
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
|
||||
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
|
||||
@@ -345,7 +399,19 @@ func Run(opt Options) error {
|
||||
bus.SetPendingStore(st)
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc}
|
||||
authzSlot := &projectAuthz{}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
ok, err := authzSlot.Authorize(owner, projectID)
|
||||
return err == nil && ok
|
||||
})
|
||||
// EXT-14:命令即工具——带 exposeAsTool 的命令投影进回合工具清单,模型调用
|
||||
// 经命令总分发(Entrance=agent,同审计信封与高危内联确认卡)。
|
||||
mgr.SetCommandSource(busAdapter{bus: bus})
|
||||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||||
wireScopedHostFactory(caps)
|
||||
applyCompiled(skillsRoot, bus, caps)
|
||||
mgr.OnUsage = func(owner string, tokens int) {
|
||||
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
|
||||
@@ -369,11 +435,33 @@ func Run(opt Options) error {
|
||||
hostSrv.Search = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
return caps.searchDefault(ctx, owner, text)
|
||||
}
|
||||
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
|
||||
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
own, err := caps.searchDefault(ctx, owner, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
|
||||
ok, aerr := authzSlot.Authorize(owner, projectID)
|
||||
return aerr == nil && ok
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(own, shared...), nil
|
||||
}
|
||||
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
|
||||
return caps.complete(ctx, owner, prompt)
|
||||
}
|
||||
|
||||
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
|
||||
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
|
||||
srv.SetProjectAuthorizer(authzSlot.Authorize)
|
||||
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
|
||||
srv.DisableUploadIndexing = opt.DisableUploadIndexing
|
||||
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
|
||||
cardSvc.MirrorConvCards = !opt.DisableConvCardMirror
|
||||
|
||||
srv.Workflows = wfReg
|
||||
mgr.SetWorkflows(wfReg)
|
||||
srv.Cards = cardSvc
|
||||
@@ -632,3 +720,27 @@ func splitKeyPool(s string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
|
||||
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
|
||||
type busAdapter struct{ bus *cmdbus.Bus }
|
||||
|
||||
func (a busAdapter) CommandTools() []runmgr.CommandTool {
|
||||
src := a.bus.CommandTools()
|
||||
out := make([]runmgr.CommandTool, len(src))
|
||||
for i, ct := range src {
|
||||
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a busAdapter) DispatchCommand(ctx context.Context, req runmgr.CommandRequest) runmgr.CommandResponse {
|
||||
resp := a.bus.Dispatch(ctx, cmdbus.Request{
|
||||
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
|
||||
})
|
||||
return runmgr.CommandResponse{
|
||||
State: string(resp.Receipt.State), Reason: resp.Receipt.Reason,
|
||||
Payload: resp.Payload, Card: resp.Card != nil,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -47,6 +48,21 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
|
||||
})
|
||||
}
|
||||
|
||||
// AddIntent 意图声明(G2-2):汇入总线意图表;插件身份随声明归因。
|
||||
func (r pluginRegistry) AddIntent(spec json.RawMessage) error {
|
||||
if r.bus == nil {
|
||||
return nil
|
||||
}
|
||||
var is extension.IntentSpec
|
||||
if err := json.Unmarshal(spec, &is); err != nil {
|
||||
return fmt.Errorf("extension: intent spec: %w", err)
|
||||
}
|
||||
return r.bus.Intents().Declare(cmdbus.Intent{
|
||||
ID: is.ID, Phrases: is.Phrases, Command: is.Command,
|
||||
Params: is.Params, Description: is.Description, Plugin: r.pluginID,
|
||||
})
|
||||
}
|
||||
|
||||
func (r compiledRegistry) AddSkill(name, markdown string) error {
|
||||
if r.skillsRoot == "" || name == "" {
|
||||
return nil
|
||||
@@ -65,6 +81,24 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
|
||||
return r.bus.RegisterPluginCommands([]json.RawMessage{spec})
|
||||
}
|
||||
|
||||
// RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。
|
||||
func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error {
|
||||
return errors.New("extension: declarative plugins cannot register resource authorizers")
|
||||
}
|
||||
|
||||
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
|
||||
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
|
||||
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
|
||||
// 拒绝,register() 整体中止,插件命令全部未注册(RDM 二进制 rdm.* 全部
|
||||
// 「unknown command」;MS1 重验收烟测实测发现)。
|
||||
func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
|
||||
if fn == nil {
|
||||
return errors.New("extension: " + r.pluginID + ": nil resource authorizer")
|
||||
}
|
||||
r.caps.authz.set(r.pluginID, fn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
|
||||
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// fakeExtAuthz 进程内扩展:注册 EXT-8 适配器+一条命令——修复前适配器注册被
|
||||
// 声明式分支拒绝,register() 中止导致命令全部丢失。
|
||||
type fakeExtAuthz struct{}
|
||||
|
||||
func (f *fakeExtAuthz) Register(reg extension.Registry) error {
|
||||
if err := reg.RegisterResourceAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "alice", nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.AddCommandHandler([]byte(`{"id":"t.authz.ping","title":"t"}`),
|
||||
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestInprocPluginRegistersAuthorizerAndCommands(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
extension.RegisterCompiled(extension.InProc{ID: "t", Register: (&fakeExtAuthz{}).Register})
|
||||
defer extension.ResetCompiled()
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
caps := hostcaps{st: st, authz: &projectAuthz{}}
|
||||
applyCompiled("", bus, caps)
|
||||
// ① 命令已注册且可分发(修复前:适配器注册被拒 → register() 中止 →
|
||||
// 命令全部丢失,rdm.* 全部 unknown command)。
|
||||
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "t.authz.ping", Entrance: "button", Owner: "alice"}); resp.Receipt.State != "committed" {
|
||||
t.Fatalf("命令分发 = %+v(注册整体中止)", resp.Receipt)
|
||||
}
|
||||
// ② 适配器经共享槽生效:alice 放行、bob 拒绝。
|
||||
if ok, _ := caps.authz.Authorize("alice", "p1"); !ok {
|
||||
t.Fatal("alice 应放行")
|
||||
}
|
||||
if ok, _ := caps.authz.Authorize("bob", "p1"); ok {
|
||||
t.Fatal("bob 应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):进程内 Host.KnowledgeSearch
|
||||
// 的检索面在适配器放行后吃到他人项目的共享知识;未放行保持本人两路、零泄露。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newExt13Host EXT-13 装配:真实 store + rag 管线 + authz 槽 + inprocHost(bob 视角)。
|
||||
func newExt13Host(t *testing.T) (interface {
|
||||
KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error)
|
||||
}, *projectAuthz) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
authz := &projectAuthz{}
|
||||
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set, authz: authz}, pluginID: "rdm", owner: "bob"}
|
||||
// alice 已发布的项目知识(带 ProjectID;模拟发布状态机产物)。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: "p-1",
|
||||
Source: "plugin:rdm", Ref: "ext13-1", Chunk: "EXT13共享独占令牌陈述"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h, authz
|
||||
}
|
||||
|
||||
func TestInprocHostKnowledgeSearchShared(t *testing.T) {
|
||||
h, authz := newExt13Host(t)
|
||||
ctx := context.Background()
|
||||
q := core.RetrievalQuery{Text: "EXT13共享独占令牌", TopK: 5}
|
||||
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("before grant: hits=%d err=%v, want 0(未放行零泄露)", len(hits), err)
|
||||
}
|
||||
// 适配器放行 bob × p-1 → Host 检索面吃到 alice 的共享命中。
|
||||
// (槽为首个注册者生效——切换授权改闭包内变量,不重注册。)
|
||||
allowBob := true
|
||||
authz.set("rdm", func(owner string, projectID core.ID) (bool, error) {
|
||||
return allowBob && owner == "bob" && string(projectID) == "p-1", nil
|
||||
})
|
||||
hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("after grant: host search got 0 shared hits, want ≥1(EXT-13)")
|
||||
}
|
||||
// 指定他人 ProjectID 时同样经适配器(发布边界在下游状态机)。
|
||||
if hits, err := h.KnowledgeSearch(ctx, "p-1", q.Text, q.TopK); err != nil || len(hits) == 0 {
|
||||
t.Fatalf("scoped to shared project: hits=%d err=%v, want ≥1", len(hits), err)
|
||||
}
|
||||
// 撤销(同一注册内翻转授权)→ 共享命中即停、零泄露。
|
||||
allowBob = false
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("after revoke: hits=%d err=%v, want 0", len(hits), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newExt9Host EXT-9 测试装配:真实 store + rag 管线 + inprocHost(无 embedder,
|
||||
// embed=false 走全文档路径)。
|
||||
func newExt9Host(t *testing.T) (interface {
|
||||
KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error
|
||||
KnowledgeDelete(ctx context.Context, sourcePrefix string) error
|
||||
}, *store.Store, *rag.Pipeline) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set}, pluginID: "rdm", owner: "alice"}
|
||||
return h, st, pipe
|
||||
}
|
||||
|
||||
func searchAll(t *testing.T, pipe *rag.Pipeline, owner, text string) []core.RetrievalHit {
|
||||
t.Helper()
|
||||
hits, err := pipe.Retrieve(context.Background(), core.ID(owner), core.RetrievalQuery{Text: text, TopK: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("retrieve: %v", err)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// EXT-9 验收:publish 按 ref 幂等(重试不重复);delete 后检索即停且幂等;
|
||||
// manual 来源不经 host 可删(前缀强制 plugin:<自身>)。
|
||||
func TestKnowledgePublishIdempotentAndRevoke(t *testing.T) {
|
||||
h, st, pipe := newExt9Host(t)
|
||||
ctx := context.Background()
|
||||
docs := []core.KnowledgeDoc{
|
||||
{Ref: "batch-001", Chunk: "EXT9甲 独占令牌之一陈述"},
|
||||
{Ref: "batch-001", Chunk: "EXT9乙 独占令牌之二陈述"},
|
||||
}
|
||||
if err := h.KnowledgePublish(ctx, docs, false); err != nil {
|
||||
t.Fatalf("publish#1: %v", err)
|
||||
}
|
||||
if err := h.KnowledgePublish(ctx, docs, false); err != nil { // 重试幂等
|
||||
t.Fatalf("publish#2: %v", err)
|
||||
}
|
||||
hits := searchAll(t, pipe, "alice", "独占令牌")
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("after double publish: hits=%d, want 2(幂等不重复;got %+v)", len(hits), hits)
|
||||
}
|
||||
|
||||
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if hits = searchAll(t, pipe, "alice", "独占令牌"); len(hits) != 0 {
|
||||
t.Fatalf("after delete: hits=%d, want 0(撤销即停)", len(hits))
|
||||
}
|
||||
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil { // 幂等
|
||||
t.Fatalf("delete#2: %v", err)
|
||||
}
|
||||
|
||||
// manual 来源:host 前缀强制拒绝,且内容不受插件删除影响。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", Source: "manual", Ref: "m-1", Chunk: "manual 手册独占词"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.KnowledgeDelete(ctx, "manual"); err == nil {
|
||||
t.Fatal("delete(manual) must be rejected: plugins may only revoke their own prefix")
|
||||
}
|
||||
if hits = searchAll(t, pipe, "alice", "手册独占词"); len(hits) != 1 {
|
||||
t.Fatalf("manual row must survive: hits=%d", len(hits))
|
||||
}
|
||||
}
|
||||
+121
-5
@@ -1,9 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/internal/ingest"
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
@@ -15,14 +18,68 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
}
|
||||
|
||||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||||
type projectAuthz struct {
|
||||
mu sync.Mutex
|
||||
holder string // 持有者 pluginID(首个注册者生效)
|
||||
fn extension.ResourceAuthorizer
|
||||
}
|
||||
|
||||
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.fn != nil {
|
||||
return // 首个注册者生效;后来者忽略(调用方经日志可查)
|
||||
}
|
||||
a.holder, a.fn = pluginID, fn
|
||||
}
|
||||
|
||||
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
|
||||
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
|
||||
a.mu.Lock()
|
||||
fn := a.fn
|
||||
a.mu.Unlock()
|
||||
if fn == nil || owner == "" || projectID == "" {
|
||||
return false, nil
|
||||
}
|
||||
return fn(owner, projectID)
|
||||
}
|
||||
|
||||
// wireScopedHostFactory EXT-11:把「按 owner+pluginID 构造受限 Host」注入公开面
|
||||
// (extension.SetScopedHostFactory)。组合根在 hostcaps 就绪后调用;重建句柄与
|
||||
// 分发期同构(owner 作用域、来源前缀强制、KV/任务台账隔离)。
|
||||
func wireScopedHostFactory(caps hostcaps) {
|
||||
extension.SetScopedHostFactory(func(owner, pluginID string) extension.Host {
|
||||
return inprocHost{caps: caps, pluginID: pluginID, owner: owner}
|
||||
})
|
||||
}
|
||||
|
||||
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
|
||||
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
|
||||
// 的具体类型断言散落)。
|
||||
type sharedRetriever interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
|
||||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||||
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
|
||||
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
|
||||
if c.authz != nil {
|
||||
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
|
||||
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
|
||||
ok, err := c.authz.Authorize(owner, pid)
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), q)
|
||||
}
|
||||
|
||||
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
|
||||
@@ -39,6 +96,21 @@ func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, e
|
||||
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
|
||||
}
|
||||
|
||||
// maxParseInput 与上传端点的读取上限同口径(server.upload 的 100<<20)。
|
||||
const maxParseInput = 100 << 20
|
||||
|
||||
// parse 走基座内置解析器(EXT-5):纯计算、不落 owner 数据;不可解析由
|
||||
// Warnings 显式表达,只有取消与超限是 error。
|
||||
func (c hostcaps) parse(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return core.ParsedDocument{}, err
|
||||
}
|
||||
if len(content) > maxParseInput {
|
||||
return core.ParsedDocument{}, fmt.Errorf("parse input %d bytes exceeds %d limit", len(content), maxParseInput)
|
||||
}
|
||||
return ingest.Parse(filename, mime, content), nil
|
||||
}
|
||||
|
||||
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
|
||||
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
|
||||
type inprocHost struct {
|
||||
@@ -75,7 +147,7 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text
|
||||
|
||||
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
|
||||
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
|
||||
//(embedding 失败降级纯全文,不阻断写入)。
|
||||
// (embedding 失败降级纯全文,不阻断写入)。
|
||||
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
@@ -93,6 +165,44 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
|
||||
}
|
||||
|
||||
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
|
||||
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
|
||||
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
|
||||
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
refs := map[string]bool{}
|
||||
for i := range docs {
|
||||
docs[i].Owner = core.ID(h.owner)
|
||||
if docs[i].Source == "" {
|
||||
docs[i].Source = "plugin:" + h.pluginID
|
||||
}
|
||||
if docs[i].Ref != "" {
|
||||
refs[docs[i].Ref] = true
|
||||
}
|
||||
}
|
||||
for ref := range refs {
|
||||
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return h.KnowledgeWrite(ctx, docs, embed)
|
||||
}
|
||||
|
||||
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
|
||||
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
|
||||
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
|
||||
if sourcePrefix != "plugin:"+h.pluginID {
|
||||
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
|
||||
}
|
||||
_, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
|
||||
return err
|
||||
}
|
||||
|
||||
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
|
||||
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
|
||||
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
|
||||
@@ -114,3 +224,9 @@ func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
|
||||
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
|
||||
return h.caps.st.ArtifactContent(h.owner, artifactID)
|
||||
}
|
||||
|
||||
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
|
||||
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
|
||||
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||||
return h.caps.parse(ctx, filename, mime, content)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
@@ -193,3 +195,112 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) {
|
||||
t.Fatalf("vector owner leak: %+v", bhits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInprocHostParseDocumentOwnerScope:EXT-5 解析方法的 owner 作用域——
|
||||
// alice 经句柄读自己的工件并解析;bob 用同一工件 ID 在 ArtifactContent 处
|
||||
// 即被拒(解析是纯计算,作用域边界在取原件一环)。
|
||||
func TestInprocHostParseDocumentOwnerScope(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
t.Cleanup(extension.ResetCompiled)
|
||||
extension.RegisterCompiled(extension.InProc{
|
||||
ID: "tst",
|
||||
Register: func(reg extension.Registry) error {
|
||||
return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.parse","title":"解析"}`),
|
||||
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
id, _ := req.Params["artifactId"].(string)
|
||||
content, err := h.ArtifactContent(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := h.ParseDocument(ctx, "报价.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"parser": doc.Meta.Parser,
|
||||
"warnings": len(doc.Warnings),
|
||||
"blocks": len(doc.Blocks),
|
||||
}, nil
|
||||
})
|
||||
},
|
||||
})
|
||||
bus, st := newInprocTestApp(t, nil)
|
||||
|
||||
art := core.Artifact{ProjectID: "p1", Owner: "alice", Name: "报价.xlsx", Kind: "document"}
|
||||
if err := st.CreateArtifact(&art, testXLSXBytes(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artID := art.ID
|
||||
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "alice", Entrance: "chat",
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("alice dispatch: %+v %s", resp.Receipt, resp.Receipt.Reason)
|
||||
}
|
||||
m, _ := resp.Payload.(map[string]any)
|
||||
if m["parser"] != "builtin:xlsx" || m["warnings"] != 0 || m["blocks"] == 0 {
|
||||
t.Fatalf("alice payload: %+v", m)
|
||||
}
|
||||
|
||||
// bob 同一工件 ID:取原件即被拒(跨 owner 不可见)。
|
||||
resp = bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "bob", Entrance: "chat",
|
||||
})
|
||||
if resp.Receipt.State == agentcommand.StateCommitted {
|
||||
t.Fatalf("bob must not read alice's artifact: %+v", resp.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInprocHostParseDocumentLimits:仅有的两条 error 路径——ctx 取消与输入
|
||||
// 超过 100MiB 上限;不可解析格式仍是带 Warnings 的显式失败。
|
||||
func TestInprocHostParseDocumentLimits(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "alice"}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := h.ParseDocument(ctx, "a.xlsx", "", []byte("x")); err == nil {
|
||||
t.Fatal("canceled ctx must error")
|
||||
}
|
||||
if _, err := h.ParseDocument(context.Background(), "a.xlsx", "", make([]byte, maxParseInput+1)); err == nil {
|
||||
t.Fatal("oversized input must error")
|
||||
}
|
||||
doc, err := h.ParseDocument(context.Background(), "旧.xls", "", []byte{0xD0, 0xCF})
|
||||
if err != nil {
|
||||
t.Fatalf("legacy format is a visible failure, not an error: %v", err)
|
||||
}
|
||||
if doc.Meta.Parser != "builtin:legacy-office" || len(doc.Warnings) != 1 {
|
||||
t.Fatalf("legacy doc: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// testXLSXBytes 构造最小可用 xlsx(单表两行),供句柄测试解析。
|
||||
func testXLSXBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
files := map[string]string{
|
||||
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="报价" sheetId="1" r:id="rId1"/></sheets></workbook>`,
|
||||
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`,
|
||||
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
|
||||
<row r="1"><c r="A1" t="inlineStr"><is><t>物料</t></is></c><c r="B1" t="inlineStr"><is><t>单价</t></is></c></row>
|
||||
<row r="2"><c r="A2" t="inlineStr"><is><t>支架</t></is></c><c r="B2"><v>12.5</v></c></row>
|
||||
</sheetData></worksheet>`,
|
||||
}
|
||||
for _, name := range []string{"xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml"} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte(files[name]))
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 慢通道开关默认关(行为与 I1 一致的机器证明):
|
||||
// 两条 runtime.intentSemantic / runtime.intentGenerator 必须注册且默认 false。
|
||||
func TestIntentSlowLaneFlagsDefaultOff(t *testing.T) {
|
||||
seen := map[string]core.SettingDesc{}
|
||||
for _, d := range settings.Schema() {
|
||||
seen[d.Key] = d
|
||||
}
|
||||
for _, key := range []string{"runtime.intentSemantic", "runtime.intentGenerator"} {
|
||||
d, ok := seen[key]
|
||||
if !ok {
|
||||
t.Fatalf("设置未注册:%s", key)
|
||||
}
|
||||
if d.Type != core.SettingBool {
|
||||
t.Fatalf("%s 类型应为 bool,得到 %v", key, d.Type)
|
||||
}
|
||||
if def, isBool := d.Default.(bool); !isBool || def {
|
||||
t.Fatalf("%s 必须默认 false,得到 %v", key, d.Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 遥测开关默认安全(N3 WP4):otlpEndpoint 默认空(不导出,保持现状),
|
||||
// serviceName 有非空默认。两处都注册进 Schema。
|
||||
func TestTelemetryFlagsDefaults(t *testing.T) {
|
||||
seen := map[string]core.SettingDesc{}
|
||||
for _, d := range settings.Schema() {
|
||||
seen[d.Key] = d
|
||||
}
|
||||
if d, ok := seen["telemetry.otlpEndpoint"]; !ok || d.Default != "" {
|
||||
t.Fatalf("otlpEndpoint 必须注册且默认空:%+v", d)
|
||||
}
|
||||
if d, ok := seen["telemetry.serviceName"]; !ok || d.Default != "agent-system-backend" {
|
||||
t.Fatalf("serviceName 必须注册且默认 agent-system-backend:%+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
// EXT-11 红测:注入前 NewScopedHost fail-closed;注入后重建句柄与分发期同构
|
||||
// (owner 作用域、PluginID、KV 隔离、知识删除前缀强制)。
|
||||
func TestScopedHostFactory(t *testing.T) {
|
||||
// 注入前:fail-closed。
|
||||
if _, err := extension.NewScopedHost("alice", "rdm"); err == nil {
|
||||
t.Fatal("before wiring, NewScopedHost must fail closed(工厂未注入)")
|
||||
}
|
||||
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
caps := hostcaps{st: st, pipe: pipe, set: set}
|
||||
|
||||
// 组合根同款注入(app.Run 内以 wireScopedHostFactory 接线;此处直接调内部函数)。
|
||||
wireScopedHostFactory(caps)
|
||||
t.Cleanup(func() { extension.SetScopedHostFactory(nil) })
|
||||
|
||||
h, err := extension.NewScopedHost("alice", "rdm")
|
||||
if err != nil {
|
||||
t.Fatalf("NewScopedHost: %v", err)
|
||||
}
|
||||
if h.Owner() != "alice" || h.PluginID() != "rdm" {
|
||||
t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID())
|
||||
}
|
||||
// KV 往返落 owner 作用域。
|
||||
if err := h.KVPut("k1", "v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok, err := h.KVGet("k1"); err != nil || !ok || v != "v1" {
|
||||
t.Fatalf("KVGet = %q ok=%v err=%v", v, ok, err)
|
||||
}
|
||||
// 同构性:知识删除前缀强制(manual 不可及)。
|
||||
if err := h.KnowledgeDelete(context.Background(), "manual"); err == nil {
|
||||
t.Fatal("scoped host must keep the plugin:<id> prefix enforcement")
|
||||
}
|
||||
// 空 owner/pluginID:拒绝。
|
||||
if _, err := extension.NewScopedHost("", "rdm"); err == nil {
|
||||
t.Fatal("empty owner must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -27,15 +27,18 @@ var (
|
||||
const cardAppendWindow = 250 * time.Millisecond
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
}
|
||||
|
||||
func New(st *store.Store, emit Emitter) *Service {
|
||||
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}}
|
||||
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
|
||||
}
|
||||
|
||||
// emitCard 按卡归属发布卡片事件(D50):
|
||||
@@ -52,6 +55,11 @@ func (s *Service) emitCard(c core.ActivityCard, typ core.StreamEventType, payloa
|
||||
if err := s.emit(c.ConversationID, c.SourceRunID, typ, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
// EXT-12 最小开关:会话卡不镜像进项目公共流(不发布即不持久,
|
||||
// 重放天然不含会话卡);项目级卡(无会话归属)不受影响。
|
||||
if !s.MirrorConvCards {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if c.ProjectID != "" {
|
||||
return s.emit(string(core.ProjectChannelKey(c.ProjectID)), c.SourceRunID, typ, payload)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package cardsvc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EXT-12 最小开关(红测):MirrorConvCards=false 时会话卡不镜像进项目公共流
|
||||
// (不发布即不持久,重放天然不含会话卡);项目级卡不受影响;缺省 true 保持现状。
|
||||
func TestConvCardMirrorSwitch(t *testing.T) {
|
||||
newSvc := func(mirror bool) (*Service, *[]string) {
|
||||
chans := []string{}
|
||||
emit := func(convID, runID string, typ core.StreamEventType, payload any) error {
|
||||
chans = append(chans, convID)
|
||||
return nil
|
||||
}
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/cards.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := New(st, emit)
|
||||
svc.MirrorConvCards = mirror
|
||||
return svc, &chans
|
||||
}
|
||||
card := core.ActivityCard{ConversationID: "c-priv", ProjectID: "p-1", Kind: core.CardKindProgress, Title: "私有过程卡"}
|
||||
|
||||
// 开(缺省现状):会话流 + 项目流双发。
|
||||
svc, chans := newSvc(true)
|
||||
if _, err := svc.Create("alice", card, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*chans) != 2 {
|
||||
t.Fatalf("mirror on: emitted %v, want 会话流+项目流", *chans)
|
||||
}
|
||||
|
||||
// 关:只进本人会话流,项目公共流零事件。
|
||||
svc2, chans2 := newSvc(false)
|
||||
if _, err := svc2.Create("alice", card, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*chans2) != 1 || (*chans2)[0] != "c-priv" {
|
||||
t.Fatalf("mirror off: emitted %v, want 仅会话流 c-priv(项目流不得发布)", *chans2)
|
||||
}
|
||||
|
||||
// 项目级卡(无会话归属)不受开关影响。
|
||||
pc := core.ActivityCard{ProjectID: "p-1", Kind: core.CardKindProgress, Title: "项目级卡"}
|
||||
if _, err := svc2.Create("alice", pc, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*chans2) != 2 || (*chans2)[1] != string(core.ProjectChannelKey("p-1")) {
|
||||
t.Fatalf("project-level card must still publish: %v", *chans2)
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@ import (
|
||||
"agent-system-core"
|
||||
)
|
||||
|
||||
// AuditStore 审计落盘端口(store 实现;v7 command_audit)。
|
||||
// AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。
|
||||
// owner 是请求租户(req.Owner):审计行按租户归属,查询按 owner 过滤,
|
||||
// 跨租户审计不可见(信封 Actor 是发起主体,不作归属依据)。
|
||||
type AuditStore interface {
|
||||
AppendAudit(env agentcommand.AuditEnvelope) error
|
||||
AppendAudit(owner string, env agentcommand.AuditEnvelope) error
|
||||
}
|
||||
|
||||
// CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。
|
||||
@@ -32,6 +34,37 @@ type CardPort interface {
|
||||
// Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。
|
||||
type Handler func(ctx context.Context, req Request) (any, error)
|
||||
|
||||
// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。
|
||||
// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。
|
||||
type CommandTool struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
Params json.RawMessage
|
||||
Risk string
|
||||
}
|
||||
|
||||
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
|
||||
func (b *Bus) CommandTools() []CommandTool {
|
||||
out := []CommandTool{}
|
||||
for _, r := range b.reg.List() {
|
||||
if !r.Enabled || r.Spec.ExposeAsTool == nil {
|
||||
continue
|
||||
}
|
||||
risk := r.Spec.ExposeAsTool.Risk
|
||||
if risk == "" {
|
||||
risk = "low"
|
||||
}
|
||||
desc := r.Spec.Description
|
||||
if desc == "" {
|
||||
desc = r.Spec.Title
|
||||
}
|
||||
out = append(out, CommandTool{ID: r.Spec.ID, Title: r.Spec.Title,
|
||||
Description: desc, Params: r.Spec.ParamsSchema, Risk: risk})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Request 一次分发请求(入口归一后的统一形态)。
|
||||
type Request struct {
|
||||
ID string
|
||||
@@ -67,6 +100,7 @@ type Bus struct {
|
||||
pendMu sync.Mutex
|
||||
pending map[string]pending
|
||||
pendingStore PendingStore
|
||||
intents *IntentStore // G2-2:意图声明表(自然语言路由)
|
||||
}
|
||||
|
||||
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
|
||||
@@ -82,6 +116,7 @@ func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus {
|
||||
return &Bus{
|
||||
reg: agentcommand.NewRegistry(), audit: audit, cards: cards, now: now,
|
||||
handlers: map[string]Handler{}, slash: map[string]string{}, pending: map[string]pending{},
|
||||
intents: NewIntentStore(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +171,19 @@ func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
|
||||
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
|
||||
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
|
||||
}
|
||||
b.bindPluginSlash(spec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindPluginSlash EXT-10②:插件命令规格自带的 slash 别名绑定(与内置命令同一张
|
||||
// 别名表——四入口同源:slash 命中即同一命令 id、同一审计信封)。
|
||||
func (b *Bus) bindPluginSlash(spec agentcommand.CommandSpec) {
|
||||
if spec.Slash != "" {
|
||||
b.slash[strings.ToLower(spec.Slash)] = spec.ID
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPluginCommand 注册进程内插件命令(EXT-1):登记规格并绑定宿主处理器。
|
||||
// 仅登记规格的声明式路径仍走 RegisterPluginCommands(无 handler,分发即 rejected)。
|
||||
func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) error {
|
||||
@@ -149,9 +193,13 @@ func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) er
|
||||
if h != nil {
|
||||
b.handlers[spec.ID] = h
|
||||
}
|
||||
b.bindPluginSlash(spec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intents 意图声明表(G2-2:注册面与路由查询共用)。
|
||||
func (b *Bus) Intents() *IntentStore { return b.intents }
|
||||
|
||||
// ResolveSlash `/` 入口解析:返回命令 id 与原始字段(参数由宿主处理器按命令语义解析)。
|
||||
func (b *Bus) ResolveSlash(cmd string) (string, bool) {
|
||||
id, ok := b.slash[strings.ToLower(strings.TrimSpace(cmd))]
|
||||
@@ -275,7 +323,7 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
|
||||
if _, src, err := b.reg.Resolve(req.ID); err == nil {
|
||||
env.Source = src
|
||||
}
|
||||
if err := b.audit.AppendAudit(env); err != nil {
|
||||
if err := b.audit.AppendAudit(req.Owner, env); err != nil {
|
||||
// 审计不可用:结果标记未知,不臆断成功(四态语义)。
|
||||
return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type memAudit struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (m *memAudit) AppendAudit(env agentcommand.AuditEnvelope) error {
|
||||
func (m *memAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
|
||||
if m.fail {
|
||||
return errors.New("audit down")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package cmdbus
|
||||
|
||||
// EXT-14:Bus.CommandTools 只投影带 ExposeAsTool 的已启用命令;risk 缺省 low、
|
||||
// description 缺省取 title;未暴露/未启用的命令不出现。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
func newToolBus(t *testing.T) *Bus {
|
||||
t.Helper()
|
||||
return New(nopAudit{}, nil, func() time.Time { return time.Now().UTC() })
|
||||
}
|
||||
|
||||
func TestCommandToolsFiltering(t *testing.T) {
|
||||
b := newToolBus(t)
|
||||
specs := []json.RawMessage{
|
||||
[]byte(`{"id":"rdm.trace","title":"追溯查询","description":"查网","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"medium"}}`),
|
||||
[]byte(`{"id":"rdm.baseline","title":"基线视图","exposeAsTool":{}}`),
|
||||
[]byte(`{"id":"rdm.hidden","title":"未暴露","description":"不出现"}`),
|
||||
}
|
||||
if err := b.RegisterPluginCommands(specs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools := b.CommandTools()
|
||||
byID := map[string]CommandTool{}
|
||||
for _, ct := range tools {
|
||||
byID[ct.ID] = ct
|
||||
}
|
||||
if len(byID) != 2 {
|
||||
t.Fatalf("projected = %+v, want 2(rdm.trace + rdm.baseline)", tools)
|
||||
}
|
||||
if ct := byID["rdm.trace"]; ct.Risk != "medium" || ct.Description != "查网" || string(ct.Params) != `{"type":"object"}` {
|
||||
t.Fatalf("rdm.trace = %+v", ct)
|
||||
}
|
||||
if ct := byID["rdm.baseline"]; ct.Risk != "low" || ct.Description != "基线视图" {
|
||||
t.Fatalf("rdm.baseline = %+v(risk 缺省 low、description 缺省 title)", ct)
|
||||
}
|
||||
if _, ok := byID["rdm.hidden"]; ok {
|
||||
t.Fatal("未声明 exposeAsTool 的命令不得投影")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandToolDispatchEnvelope(t *testing.T) {
|
||||
b := newToolBus(t)
|
||||
// 注册一个带处理器的暴露命令,经 Dispatch 分发验证完整回执链路。
|
||||
var spec agentcommand.CommandSpec
|
||||
if err := json.Unmarshal([]byte(`{"id":"rdm.echo","title":"回声","exposeAsTool":{"risk":"low"}}`), &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.RegisterPluginCommand(spec, func(ctx context.Context, req Request) (any, error) {
|
||||
return map[string]any{"echo": req.Params["v"], "actor": req.Actor, "entrance": req.Entrance}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := b.Dispatch(context.Background(), Request{ID: "rdm.echo", Params: map[string]any{"v": 1},
|
||||
Entrance: "agent", Actor: "bob", Owner: "bob", ConversationID: "cnv-1"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("receipt = %+v (%s)", resp.Receipt, resp.Receipt.Reason)
|
||||
}
|
||||
m, _ := resp.Payload.(map[string]any)
|
||||
if m["entrance"] != "agent" || m["actor"] != "bob" {
|
||||
t.Fatalf("payload = %+v(Entrance=agent 原样入审计)", m)
|
||||
}
|
||||
}
|
||||
|
||||
type nopAudit struct{}
|
||||
|
||||
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package cmdbuf 意图路由(G2-2:Intent 声明面与自然语言入口)。
|
||||
// 插件经 extension.Registry.AddIntent 声明「短语 → 命令」映射;宿主把声明汇入
|
||||
// 本表。匹配口径:文本归一(去空白/小写)后做包含匹配,最长短语优先——
|
||||
// 无模型参与的确定性第一档;路由终点仍是命令总线(同审计信封与 HITL 收口)。
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Intent 一条意图声明:短语命中后分发到 CommandID(参数模板随声明携带)。
|
||||
type Intent struct {
|
||||
ID string `json:"id"` // 形如 <domain>.<intent>(如 rdm.write-proposal)
|
||||
Phrases []string `json:"phrases"` // 触发短语(归一后包含匹配)
|
||||
Command string `json:"command"` // 目标命令 id(须已注册)
|
||||
Params map[string]any `json:"params,omitempty"` // 参数模板(可被调用方覆盖)
|
||||
Description string `json:"description,omitempty"`
|
||||
Plugin string `json:"plugin"` // 声明方(审计归因)
|
||||
}
|
||||
|
||||
// IntentStore 意图声明表(进程内;插件注册期写入,运行期只读)。
|
||||
type IntentStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]Intent
|
||||
phrases []string // 归一短语(排序后长词优先匹配)
|
||||
}
|
||||
|
||||
// NewIntentStore 建表。
|
||||
func NewIntentStore() *IntentStore {
|
||||
return &IntentStore{byID: map[string]Intent{}}
|
||||
}
|
||||
|
||||
// normalize 意图匹配归一:小写、压缩空白。
|
||||
func normalize(s string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||||
}
|
||||
|
||||
// Declare 登记意图(同 id 覆盖;短语与命令必填——fail-closed)。
|
||||
func (s *IntentStore) Declare(in Intent) error {
|
||||
if in.ID == "" || in.Command == "" || len(in.Phrases) == 0 {
|
||||
return fmt.Errorf("cmdbus: intent %q 声明不完整(id/phrases/command 必填)", in.ID)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.byID[in.ID] = in
|
||||
s.rebuild()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IntentStore) rebuild() {
|
||||
s.phrases = s.phrases[:0]
|
||||
for _, in := range s.byID {
|
||||
for _, p := range in.Phrases {
|
||||
s.phrases = append(s.phrases, normalize(p))
|
||||
}
|
||||
}
|
||||
// 长短语优先:更具体的意图先命中。
|
||||
sort.Slice(s.phrases, func(i, j int) bool { return len(s.phrases[i]) > len(s.phrases[j]) })
|
||||
}
|
||||
|
||||
// Match 自然语言匹配:返回命中的意图(ok=false=未命中)。
|
||||
func (s *IntentStore) Match(text string) (Intent, bool) {
|
||||
n := normalize(text)
|
||||
if n == "" {
|
||||
return Intent{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, ph := range s.phrases {
|
||||
if ph != "" && strings.Contains(n, ph) {
|
||||
for _, in := range s.byID {
|
||||
for _, p := range in.Phrases {
|
||||
if normalize(p) == ph {
|
||||
return in, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Intent{}, false
|
||||
}
|
||||
|
||||
// List 全部意图声明(清单面)。
|
||||
func (s *IntentStore) List() []Intent {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Intent, 0, len(s.byID))
|
||||
for _, in := range s.byID {
|
||||
out = append(out, in)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
// EXT-10② 红测:插件命令规格自带 slash 别名 → 四入口同源(slash 命中同一命令
|
||||
// id,经总线分发同审计信封)。
|
||||
func TestPluginSlashAliasBindsAndDispatches(t *testing.T) {
|
||||
bus := New(nopAudit{}, nil, nil)
|
||||
called := ""
|
||||
if err := bus.RegisterPluginCommand(spec(t, `{
|
||||
"id": "rdm.ping", "title": "自检", "slash": "/rdm.ping"
|
||||
}`), func(ctx context.Context, req Request) (any, error) {
|
||||
called = req.ID + "|" + req.Entrance
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, ok := bus.ResolveSlash("/rdm.ping")
|
||||
if !ok || id != "rdm.ping" {
|
||||
t.Fatalf("ResolveSlash = %q ok=%v(插件别名未绑定)", id, ok)
|
||||
}
|
||||
// 大小写与首尾空白归一。
|
||||
if id, ok := bus.ResolveSlash(" /RDM.PING "); !ok || id != "rdm.ping" {
|
||||
t.Fatalf("归一解析失败: %q %v", id, ok)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: id, Entrance: "slash", Owner: "alice"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("slash 分发 = %+v", resp.Receipt)
|
||||
}
|
||||
if called != "rdm.ping|slash" {
|
||||
t.Fatalf("处理器见到 %q(须同命令 id)", called)
|
||||
}
|
||||
// 同一命令按钮入口分发——同源同信封。
|
||||
if resp2 := bus.Dispatch(context.Background(), Request{ID: "rdm.ping", Entrance: "button", Owner: "alice"}); resp2.Receipt.State != "committed" {
|
||||
t.Fatalf("button 分发 = %+v", resp2.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
// G2-2 红测:意图声明→归一包含匹配(最长短语优先)→路由到命令(chat 入口)。
|
||||
func TestIntentDeclareMatchRoute(t *testing.T) {
|
||||
bus := New(nopAudit{}, nil, nil)
|
||||
_ = bus.RegisterPluginCommand(spec(t, `{
|
||||
"id": "rdm.draft.compose", "title": "起草"
|
||||
}`), func(ctx context.Context, req Request) (any, error) {
|
||||
return map[string]any{"sectionId": req.Params["sectionId"]}, nil
|
||||
})
|
||||
if err := bus.Intents().Declare(Intent{
|
||||
ID: "rdm.write-proposal", Phrases: []string{"帮我写项目建议书", "写项目建议书"},
|
||||
Command: "rdm.draft.compose", Params: map[string]any{"sectionId": "sec-1"},
|
||||
Plugin: "rdm",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 未命中。
|
||||
if _, ok := bus.Intents().Match("今天天气如何"); ok {
|
||||
t.Fatal("无关文本不得命中")
|
||||
}
|
||||
// 命中(含大小写/空白归一)。
|
||||
in, ok := bus.Intents().Match("请 帮我写项目建议书,尽快")
|
||||
if !ok || in.ID != "rdm.write-proposal" {
|
||||
t.Fatalf("匹配 = %+v ok=%v", in, ok)
|
||||
}
|
||||
// 路由:匹配到的命令经总线 chat 入口分发成功。
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: in.Command, Params: in.Params, Entrance: "chat", Owner: "alice"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("路由分发 = %+v", resp.Receipt)
|
||||
}
|
||||
// 声明校验:缺短语拒绝。
|
||||
if err := bus.Intents().Declare(Intent{ID: "x", Command: "y"}); err == nil {
|
||||
t.Fatal("不完整声明须拒绝")
|
||||
}
|
||||
// 最长短语优先:更具体的意图先命中。
|
||||
if err := bus.Intents().Declare(Intent{
|
||||
ID: "rdm.write-competitive", Phrases: []string{"写竞品分析"},
|
||||
Command: "rdm.draft.compose", Plugin: "rdm",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in, _ := bus.Intents().Match("帮我写项目建议书"); in.ID != "rdm.write-proposal" {
|
||||
t.Fatalf("长短语意图应先命中: %+v", in)
|
||||
}
|
||||
}
|
||||
|
||||
func spec(t *testing.T, raw string) agentcommand.CommandSpec {
|
||||
t.Helper()
|
||||
var s agentcommand.CommandSpec
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
+31
-12
@@ -26,6 +26,11 @@ type Config struct {
|
||||
Instance string `json:"instance"`
|
||||
InsecureListen bool `json:"insecureListen"`
|
||||
AllowedHostsRaw string `json:"allowedHosts"`
|
||||
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
|
||||
DisableExecTools bool `json:"disableExecTools"`
|
||||
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
|
||||
// 隔离档)。DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string `json:"execBackend"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -37,16 +42,18 @@ var defaults = Config{
|
||||
|
||||
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
|
||||
var envKey = map[string]string{
|
||||
"addr": "AGENT_SYSTEM_ADDR",
|
||||
"data": "AGENT_SYSTEM_DATA",
|
||||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||
"dsn": "AGENT_SYSTEM_DSN",
|
||||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"addr": "AGENT_SYSTEM_ADDR",
|
||||
"data": "AGENT_SYSTEM_DATA",
|
||||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||
"dsn": "AGENT_SYSTEM_DSN",
|
||||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
}
|
||||
|
||||
// Source 配置来源层级(数值越大优先级越高)。
|
||||
@@ -165,6 +172,10 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.InsecureListen
|
||||
case "allowedHosts":
|
||||
dst = &c.AllowedHostsRaw
|
||||
case "disableExecTools":
|
||||
dst = &c.DisableExecTools
|
||||
case "execBackend":
|
||||
dst = &c.ExecBackend
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -194,18 +205,22 @@ func stringField(c Config, f string) string {
|
||||
return c.Instance
|
||||
case "allowedHosts":
|
||||
return c.AllowedHostsRaw
|
||||
case "execBackend":
|
||||
return c.ExecBackend
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isBoolField(f string) bool {
|
||||
return f == "mock" || f == "insecureListen"
|
||||
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
|
||||
}
|
||||
|
||||
func boolField(c Config, f string) bool {
|
||||
switch f {
|
||||
case "insecureListen":
|
||||
return c.InsecureListen
|
||||
case "disableExecTools":
|
||||
return c.DisableExecTools
|
||||
default:
|
||||
return c.Mock
|
||||
}
|
||||
@@ -215,6 +230,8 @@ func setBool(c *Config, f string, v bool) {
|
||||
switch f {
|
||||
case "insecureListen":
|
||||
c.InsecureListen = v
|
||||
case "disableExecTools":
|
||||
c.DisableExecTools = v
|
||||
default:
|
||||
c.Mock = v
|
||||
}
|
||||
@@ -238,6 +255,8 @@ func setString(c *Config, f, v string) {
|
||||
c.Instance = v
|
||||
case "allowedHosts":
|
||||
c.AllowedHostsRaw = v
|
||||
case "execBackend":
|
||||
c.ExecBackend = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +278,7 @@ func (r Resolved) Explain(w io.Writer, file string) {
|
||||
fmt.Fprintln(w, "config file: (none)")
|
||||
}
|
||||
fmt.Fprintln(w, "field value source")
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts"} {
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend"} {
|
||||
v := stringField(r.Config, f)
|
||||
if isBoolField(f) {
|
||||
v = fmt.Sprintf("%t", boolField(r.Config, f))
|
||||
|
||||
@@ -123,6 +123,38 @@ func TestEnvBool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// disableExecTools:默认 false;文件、环境、CLI 三层均可打开,CLI 优先。
|
||||
func TestDisableExecToolsLayers(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{}, nil)
|
||||
if err != nil || r.DisableExecTools {
|
||||
t.Fatalf("default must be false: %v %+v", err, r.Config)
|
||||
}
|
||||
f := fileWith(t, `{"disableExecTools":true}`)
|
||||
r, err = Load(f, noEnv, Config{}, nil)
|
||||
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcFile {
|
||||
t.Fatalf("file layer: %v %+v src=%v", err, r.Config, r.SourceOf("disableExecTools"))
|
||||
}
|
||||
env := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_DISABLE_EXEC_TOOLS" {
|
||||
return "1", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
r, err = Load("", env, Config{}, nil)
|
||||
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcEnv {
|
||||
t.Fatalf("env layer: %v %+v", err, r.Config)
|
||||
}
|
||||
r, err = Load("", env, Config{DisableExecTools: false}, map[string]bool{"disableExecTools": true})
|
||||
if err != nil || r.DisableExecTools || r.SourceOf("disableExecTools") != SrcCLI {
|
||||
t.Fatalf("cli must win: %v %+v", err, r.Config)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
r.Explain(&out, "")
|
||||
if !bytes.Contains(out.Bytes(), []byte("disableExecTools")) {
|
||||
t.Fatalf("explain must list disableExecTools:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。
|
||||
func TestExplainRedactsDSN(t *testing.T) {
|
||||
env := func(k string) (string, bool) {
|
||||
|
||||
+50
-14
@@ -36,13 +36,20 @@ type CardResolver func(id core.ID) *core.ActivityCard
|
||||
// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。
|
||||
type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool)
|
||||
|
||||
// AssembleOptions 是组装的可选行为。零值全部关闭(向后兼容)。
|
||||
type AssembleOptions struct {
|
||||
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
|
||||
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
|
||||
ThinkReplay bool
|
||||
}
|
||||
|
||||
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
|
||||
// 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。
|
||||
// prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。
|
||||
// resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威
|
||||
// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填
|
||||
// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。
|
||||
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) {
|
||||
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver, opts AssembleOptions) ([]agentllm.Message, *Report) {
|
||||
rep := &Report{}
|
||||
msgs := make([]agentllm.Message, 0, len(sys)+1)
|
||||
msgs = append(msgs, sys...)
|
||||
@@ -84,7 +91,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
continue
|
||||
}
|
||||
if m.Role == core.RoleAssistant {
|
||||
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)...)
|
||||
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled, opts)...)
|
||||
continue
|
||||
}
|
||||
content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)
|
||||
@@ -102,6 +109,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
}
|
||||
|
||||
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
|
||||
msgs = append(msgs, BuildCurrentUser(eff, currentUser, attachments, knowledge, rep))
|
||||
|
||||
fillEstimate(msgs, rep)
|
||||
return msgs, rep
|
||||
}
|
||||
|
||||
// BuildCurrentUser 组装当前轮的用户消息(文本部件 + 附件解析全文 + 知识检索)。
|
||||
// 会话转录为权威上下文时,每轮只构建这一条消息,历史由转录承载——
|
||||
// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。
|
||||
func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message {
|
||||
var sb strings.Builder
|
||||
for _, p := range currentUser.Parts {
|
||||
if p.Type == core.PartText {
|
||||
@@ -118,23 +135,26 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
text := doc.PlainText()
|
||||
if runes := []rune(text); len(runes) > limit {
|
||||
text = string(runes[:limit]) + "…(附件超长截断)"
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
|
||||
if rep != nil {
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
|
||||
}
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
|
||||
if rep != nil {
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
|
||||
}
|
||||
}
|
||||
if len(knowledge) > 0 {
|
||||
sb.WriteString("\n\n[相关知识]\n")
|
||||
for i, h := range knowledge {
|
||||
fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk)
|
||||
}
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
|
||||
if rep != nil {
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()})
|
||||
|
||||
fillEstimate(msgs, rep)
|
||||
return msgs, rep
|
||||
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}
|
||||
}
|
||||
|
||||
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
|
||||
@@ -171,17 +191,24 @@ func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, reso
|
||||
// 前置文本与 tool_calls 组成一条 assistant,每个调用紧跟一条 tool 结果。
|
||||
// 没有结果的调用补一条失败占位,避免模型把中断当成成功。孤立的结果直接跳过。
|
||||
// 被守卫降级、且后面不是工具调用的过程文本不回放。
|
||||
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) []agentllm.Message {
|
||||
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int, opts AssembleOptions) []agentllm.Message {
|
||||
var out []agentllm.Message
|
||||
var text strings.Builder
|
||||
var think strings.Builder
|
||||
var calls []agentllm.ToolCall
|
||||
results := map[string]core.ToolResultPart{}
|
||||
seq := 0
|
||||
|
||||
flush := func() {
|
||||
content := strings.TrimSpace(text.String())
|
||||
thinkText := strings.TrimSpace(think.String())
|
||||
if opts.ThinkReplay && thinkText != "" {
|
||||
// 交错思考模型要求历史 assistant 原样带 <think>;只进请求,不落盘。
|
||||
content = "<think>\n" + thinkText + "\n</think>\n\n" + content
|
||||
}
|
||||
if content == "" && len(calls) == 0 {
|
||||
text.Reset()
|
||||
think.Reset()
|
||||
results = map[string]core.ToolResultPart{}
|
||||
return
|
||||
}
|
||||
@@ -202,12 +229,18 @@ func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard in
|
||||
})
|
||||
}
|
||||
text.Reset()
|
||||
think.Reset()
|
||||
calls = nil
|
||||
results = map[string]core.ToolResultPart{}
|
||||
}
|
||||
|
||||
for i, p := range m.Parts {
|
||||
switch p.Type {
|
||||
case core.PartThinking:
|
||||
if strings.TrimSpace(p.Text) != "" {
|
||||
think.WriteString(p.Text)
|
||||
think.WriteString("\n")
|
||||
}
|
||||
case core.PartText:
|
||||
if demotedProcessText(m.Parts, i) {
|
||||
continue
|
||||
@@ -381,19 +414,22 @@ func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep
|
||||
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
|
||||
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
|
||||
// (可见即信号,不静默丢弃)。
|
||||
// 占位文案不带「[产出工件 …]」方括号形态:那正是 ClaimGate 用来识别伪造调用
|
||||
// 标记的模式——历史里自己产出这种格式,下一轮模型照抄就会被终轮检查误判为
|
||||
// 「正文里出现了工具调用标记」。
|
||||
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
if resolve == nil {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
art, content, ok := resolve(a.ArtifactID)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s(二进制内容,正文见工件查看器)。\n", art.Name)
|
||||
}
|
||||
*backfilled++
|
||||
text := string(content)
|
||||
@@ -401,7 +437,7 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int,
|
||||
text = string(runes[:limit]) + "…(工件超长截断)"
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
|
||||
}
|
||||
return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text)
|
||||
return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text)
|
||||
}
|
||||
|
||||
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestAssembleInputAttachmentLimit(t *testing.T) {
|
||||
Meta: core.DocMeta{Filename: "纪要.docx"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
|
||||
last := msgs[len(msgs)-1]
|
||||
if !strings.Contains(last.Content, "纪要.docx") {
|
||||
@@ -42,7 +42,7 @@ func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) {
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}},
|
||||
}
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
|
||||
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
last := msgs[len(msgs)-1]
|
||||
if got := strings.Count(last.Content, "甲"); got != 100 {
|
||||
t.Errorf("injected chars = %d, want 100 (configured limit)", got)
|
||||
@@ -58,7 +58,7 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) {
|
||||
Meta: core.DocMeta{Filename: "b.txt"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}},
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if len(rep.Truncations) != 0 {
|
||||
t.Errorf("under-limit doc should not truncate: %v", rep.Truncations)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestAssembleInputCardBackfill(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil, AssembleOptions{})
|
||||
|
||||
var assistantIn string
|
||||
for _, m := range msgs {
|
||||
@@ -128,7 +128,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
|
||||
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil)
|
||||
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil, AssembleOptions{})
|
||||
if strings.Count(msgs[1].Content, "丁") != 100 {
|
||||
t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁"))
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// 无 resolver(收件箱关闭/卡片缺失):占位可见。
|
||||
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
|
||||
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if !strings.Contains(msgs2[1].Content, "内容见收件箱") {
|
||||
t.Errorf("fallback placeholder missing: %q", msgs2[1].Content)
|
||||
}
|
||||
@@ -159,8 +159,8 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
}
|
||||
return core.Artifact{}, nil, false
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
|
||||
if !strings.Contains(msgs[1].Content, "[产出工件 检索汇总.md]") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
|
||||
if !strings.Contains(msgs[1].Content, "此前已生成文件 检索汇总.md,内容如下:") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
t.Errorf("artifact body not backfilled: %q", msgs[1].Content)
|
||||
}
|
||||
if !contains(rep.Items, "artifact(1)") {
|
||||
@@ -172,7 +172,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
|
||||
msgs2, rep2 := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
|
||||
return art, []byte(long), true
|
||||
})
|
||||
}, AssembleOptions{})
|
||||
if strings.Count(msgs2[1].Content, "乙") != 100 {
|
||||
t.Errorf("artifact chars = %d, want 100", strings.Count(msgs2[1].Content, "乙"))
|
||||
}
|
||||
@@ -183,14 +183,14 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
// 二进制内容:占位不回灌乱码。
|
||||
msgs3, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
|
||||
return art, []byte{0xff, 0xfe, 0x00, 0x01}, true
|
||||
})
|
||||
}, AssembleOptions{})
|
||||
if !strings.Contains(msgs3[1].Content, "二进制内容") || strings.Contains(msgs3[1].Content, "关键结论") {
|
||||
t.Errorf("binary artifact should keep placeholder: %q", msgs3[1].Content)
|
||||
}
|
||||
|
||||
// 无 resolver:占位可见。
|
||||
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
|
||||
if !strings.Contains(msgs4[1].Content, "[产出工件 检索汇总.md]") {
|
||||
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if !strings.Contains(msgs4[1].Content, "此前已生成文件:检索汇总.md。") {
|
||||
t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm
|
||||
return msgs, rep
|
||||
}
|
||||
|
||||
// TailNotes 导出尾注渲染(转录权威模式下由调用方经 Reminders 逐轮注入)。
|
||||
func TailNotes(tail Tail) []string { return tailNotes(tail) }
|
||||
|
||||
func tailNotes(tail Tail) []string {
|
||||
var notes []string
|
||||
if text := strings.TrimSpace(tail.Instructions); text != "" {
|
||||
@@ -207,12 +210,12 @@ func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []age
|
||||
}
|
||||
dropped := 0
|
||||
for estimateAll(msgs) > limit {
|
||||
idx := oldestDrop(msgs)
|
||||
if idx < 0 {
|
||||
start, end := oldestDropRange(msgs)
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
msgs = append(msgs[:idx], msgs[idx+1:]...)
|
||||
dropped++
|
||||
dropped += end - start
|
||||
msgs = append(msgs[:start], msgs[end:]...)
|
||||
}
|
||||
if dropped > 0 {
|
||||
rep.Truncations = append(rep.Truncations, fmtDropped(dropped))
|
||||
@@ -226,9 +229,14 @@ func fmtDropped(n int) string {
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
func oldestDrop(msgs []agentllm.Message) int {
|
||||
// oldestDropRange 返回下一次丢弃的区间 [start, end)(找不到返回 -1,-1)。
|
||||
// 丢弃按工具组对齐:带 tool_calls 的 assistant 与其连续 tool 结果同退,
|
||||
// 开头的孤立 tool 结果组整组同退——裁切后既不剩无结果的调用,
|
||||
// 也不剩无调用的结果(与库层 alignToolTail 同语义、方向相反)。
|
||||
// 组越过最后一条(当前轮)时不再丢弃:窗口允许略超,配对不可破坏。
|
||||
func oldestDropRange(msgs []agentllm.Message) (int, int) {
|
||||
if len(msgs) < 3 {
|
||||
return -1
|
||||
return -1, -1
|
||||
}
|
||||
// 保留开头的系统前缀和最后一条(当前轮)。
|
||||
start := 0
|
||||
@@ -236,9 +244,26 @@ func oldestDrop(msgs []agentllm.Message) int {
|
||||
start++
|
||||
}
|
||||
if start >= len(msgs)-1 {
|
||||
return -1
|
||||
return -1, -1
|
||||
}
|
||||
return start
|
||||
end := start + 1
|
||||
m := msgs[start]
|
||||
switch {
|
||||
case m.Role == agentllm.RoleAssistant && len(m.ToolCalls) > 0:
|
||||
// assistant 及其连续 tool 结果整组同退。
|
||||
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
|
||||
end++
|
||||
}
|
||||
case m.Role == agentllm.RoleTool:
|
||||
// 开头即孤立 tool 结果(其 assistant 必已被丢或不存在):整组同退。
|
||||
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end > len(msgs)-1 {
|
||||
return -1, -1
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func estimateAll(msgs []agentllm.Message) int {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) {
|
||||
{ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}},
|
||||
{ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}},
|
||||
}
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil)
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
blob := ""
|
||||
for _, m := range msgs {
|
||||
blob += m.Content
|
||||
@@ -62,3 +62,118 @@ func TestTrimDropsOldestHistory(t *testing.T) {
|
||||
t.Fatal("expected truncation")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:裁切工具组对齐(验收② 产品侧机器证据)----
|
||||
|
||||
// assertNoOrphanToolPairs 双向断言:每个 tool_call 有结果,每个结果有调用。
|
||||
func assertNoOrphanToolPairs(t *testing.T, msgs []agentllm.Message) {
|
||||
t.Helper()
|
||||
calls := map[string]bool{}
|
||||
results := map[string]bool{}
|
||||
for _, m := range msgs {
|
||||
for _, c := range m.ToolCalls {
|
||||
calls[c.ID] = true
|
||||
}
|
||||
if m.Role == agentllm.RoleTool && m.ToolCallID != "" {
|
||||
results[m.ToolCallID] = true
|
||||
}
|
||||
}
|
||||
for id := range calls {
|
||||
if !results[id] {
|
||||
t.Fatalf("孤立 tool_call:%s(无结果)", id)
|
||||
}
|
||||
}
|
||||
for id := range results {
|
||||
if !calls[id] {
|
||||
t.Fatalf("悬空 tool 结果:%s(无调用)", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toolMsgs() []agentllm.Message {
|
||||
call := func(id, name string) agentllm.ToolCall {
|
||||
return agentllm.ToolCall{ID: id, Name: name, Arguments: "{}"}
|
||||
}
|
||||
return []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "s"},
|
||||
{Role: agentllm.RoleUser, Content: strings.Repeat("旧一", 300)},
|
||||
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc1", "read")}},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc1", Name: "read", Content: strings.Repeat("结果一", 200)},
|
||||
{Role: agentllm.RoleUser, Content: strings.Repeat("旧二", 300)},
|
||||
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc2", "grep"), call("tc3", "read")}},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc2", Name: "grep", Content: strings.Repeat("结果二", 200)},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc3", Name: "read", Content: strings.Repeat("结果三", 200)},
|
||||
{Role: agentllm.RoleUser, Content: "现在"},
|
||||
}
|
||||
}
|
||||
|
||||
// 连续多轮裁切后配对仍成立:assistant 与其结果同退,不逐条拆散。
|
||||
func TestTrimOldestKeepsToolPairs(t *testing.T) {
|
||||
out, rep := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 1600})
|
||||
assertNoOrphanToolPairs(t, out)
|
||||
if len(rep.Truncations) == 0 {
|
||||
t.Fatal("expected truncation")
|
||||
}
|
||||
if out[len(out)-1].Content != "现在" {
|
||||
t.Fatal("当前轮必须保留")
|
||||
}
|
||||
// 窗口再压:旧二组也须整组同退,仍不出现孤立配对。
|
||||
out2, _ := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 900})
|
||||
assertNoOrphanToolPairs(t, out2)
|
||||
}
|
||||
|
||||
// 开头即孤立 tool 结果组:整组同退(其 assistant 已不存在)。
|
||||
func TestTrimOldestDropsLeadingOrphanToolRun(t *testing.T) {
|
||||
msgs := []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "s"},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "ghost", Name: "read", Content: strings.Repeat("孤", 500)},
|
||||
{Role: agentllm.RoleUser, Content: strings.Repeat("旧", 500)},
|
||||
{Role: agentllm.RoleUser, Content: "现在"},
|
||||
}
|
||||
out, _ := FinishAssembly(msgs, &Report{}, Tail{Window: 700})
|
||||
assertNoOrphanToolPairs(t, out)
|
||||
for _, m := range out {
|
||||
if m.ToolCallID == "ghost" {
|
||||
t.Fatal("孤立结果组应整组丢弃")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具组越过当前轮时不再丢弃:窗口可略超,配对不可破坏。
|
||||
func TestTrimOldestStopsWhenGroupCrossesCurrentTurn(t *testing.T) {
|
||||
msgs := []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "s"},
|
||||
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{{ID: "tc9", Name: "read", Arguments: "{}"}}},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc9", Name: "read", Content: strings.Repeat("结果", 900)},
|
||||
}
|
||||
start, end := oldestDropRange(msgs)
|
||||
if start != -1 || end != -1 {
|
||||
t.Fatalf("组越过当前轮应返回 -1,-1,得到 %d,%d", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:PrefixHash 稳定契约(同一输入 → 同一前缀哈希)----
|
||||
|
||||
func TestPrefixHashStableForIdenticalInput(t *testing.T) {
|
||||
build := func() []agentllm.Message {
|
||||
return []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "系统前缀A"},
|
||||
{Role: agentllm.RoleSystem, Content: "系统前缀B"},
|
||||
{Role: agentllm.RoleUser, Content: "历史一"},
|
||||
{Role: agentllm.RoleAssistant, Content: "回复一"},
|
||||
{Role: agentllm.RoleUser, Content: "现在"},
|
||||
}
|
||||
}
|
||||
_, rep1 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
|
||||
_, rep2 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
|
||||
if rep1.PrefixHash == "" || rep1.PrefixHash != rep2.PrefixHash {
|
||||
t.Fatalf("同一输入的前缀哈希必须一致:%q vs %q", rep1.PrefixHash, rep2.PrefixHash)
|
||||
}
|
||||
// 前缀变化必须改变哈希(钉住哈希语义,防退化为常量)。
|
||||
other := build()
|
||||
other[0].Content = "系统前缀X"
|
||||
_, rep3 := FinishAssembly(other, &Report{}, Tail{Instructions: "个人指令"})
|
||||
if rep3.PrefixHash == rep1.PrefixHash {
|
||||
t.Fatal("前缀变化后哈希必须不同")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func assistantWith(id string, parts ...core.Part) core.Message {
|
||||
}
|
||||
|
||||
func assembleHistory(hist []core.Message, resolve ArtifactResolver) []agentllm.Message {
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestSummarizeBeyondInjectsExtract(t *testing.T) {
|
||||
}
|
||||
current := core.Message{ID: "cur", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "现在"}}}
|
||||
prof := core.AgentProfile{Context: core.ContextPolicy{MaxHistoryMessages: 2, SummarizeBeyond: true}}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
joined := ""
|
||||
for _, m := range msgs {
|
||||
joined += m.Content
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package ctxasm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 交错思考模型(MiniMax 等)要求历史 assistant 原样回传 <think>;
|
||||
// 关闭时不回传(默认兼容旧行为)。思考块只进请求,不落库。
|
||||
func TestThinkReplayWrapsAssistantHistory(t *testing.T) {
|
||||
hist := []core.Message{
|
||||
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "总结"}}},
|
||||
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{
|
||||
{Type: core.PartThinking, Text: "先读材料。", Process: true},
|
||||
{Type: core.PartText, Text: "结论是先入库。", Process: true},
|
||||
{Type: core.PartToolCall, ToolCall: &core.ToolCallPart{ID: "c1", Name: "run_python", Arguments: "{}"}},
|
||||
{Type: core.PartToolResult, ToolResult: &core.ToolResultPart{CallID: "c1", Status: "ok", Summary: "done"}},
|
||||
{Type: core.PartText, Text: "已完成。"},
|
||||
}},
|
||||
}
|
||||
on, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{ThinkReplay: true})
|
||||
var sawThink int
|
||||
for _, m := range on {
|
||||
if m.Role == agentllm.RoleAssistant && strings.HasPrefix(m.Content, "<think>") {
|
||||
sawThink++
|
||||
if !strings.Contains(m.Content, "</think>") || !strings.Contains(m.Content, "先读材料。") {
|
||||
t.Fatalf("think block malformed: %q", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawThink == 0 {
|
||||
t.Fatalf("ThinkReplay=on should inject <think> into assistant history: %+v", on)
|
||||
}
|
||||
off, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
for _, m := range off {
|
||||
if m.Role == agentllm.RoleAssistant && strings.Contains(m.Content, "<think>") {
|
||||
t.Fatalf("ThinkReplay=off must not inject think blocks: %q", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package hostapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
@@ -18,7 +18,10 @@ type Server struct {
|
||||
Store *store.Store
|
||||
Dispatch func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error
|
||||
Search func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
|
||||
Complete func(ctx context.Context, owner, prompt string) (string, error)
|
||||
// SharedSearch EXT-8 余项:非 nil 时 search 端点改用之(组合根传入
|
||||
// 「本人结果 + 适配器放行的共享追加」组合;nil=现状仅本人)。
|
||||
SharedSearch func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
|
||||
Complete func(ctx context.Context, owner, prompt string) (string, error)
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -143,7 +146,12 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "retriever unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
hits, err := s.Search(r.Context(), s.owner(r), r.URL.Query().Get("q"))
|
||||
// EXT-8 余项:共享版可用时整体切换(本人+共享追加)。
|
||||
fn := s.Search
|
||||
if s.SharedSearch != nil {
|
||||
fn = s.SharedSearch
|
||||
}
|
||||
hits, err := fn(r.Context(), s.owner(r), r.URL.Query().Get("q"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package hostapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EXT-8 余项:hostapi 检索面——SharedSearch 可用时整体切换(本人+适配器放行的
|
||||
// 共享追加);不可用时维持现状(仅本人回调)。
|
||||
func TestSharedSearchFace(t *testing.T) {
|
||||
own := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Owner: "bob", Chunk: "本人命中"}}}
|
||||
sharedHit := core.RetrievalHit{Doc: core.KnowledgeDoc{Owner: "alice", ProjectID: "prj-s", Chunk: "共享命中"}}
|
||||
|
||||
srv := &Server{Token: "t", Search: func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
return own, nil
|
||||
}}
|
||||
run := func() int {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+srv.Token)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
if code := run(); code != http.StatusOK {
|
||||
t.Fatalf("plain search: %d", code)
|
||||
}
|
||||
|
||||
srv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
return append(own, sharedHit), nil
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+srv.Token)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("shared search: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var hits []core.RetrievalHit
|
||||
if err := jsonUnmarshal(rec.Body.Bytes(), &hits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 2 || hits[1].Doc.Owner != "alice" {
|
||||
t.Fatalf("hits = %+v, want 本人+共享", hits)
|
||||
}
|
||||
}
|
||||
+171
-60
@@ -4,7 +4,6 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -34,11 +33,16 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
|
||||
doc.Meta.Confidence = 1.0
|
||||
case "docx":
|
||||
doc.Meta.Parser = "builtin:docx"
|
||||
blocks, err := parseDocx(content)
|
||||
if err != nil {
|
||||
blocks, revised, err := parseDocxRevised(content)
|
||||
switch {
|
||||
case err != nil:
|
||||
doc.Warnings = append(doc.Warnings, "docx 解析失败:"+err.Error())
|
||||
doc.Meta.Confidence = 0.3
|
||||
} else {
|
||||
case revised:
|
||||
doc.Blocks = blocks
|
||||
doc.Warnings = append(doc.Warnings, docxRevisionWarning)
|
||||
doc.Meta.Confidence = 0.85
|
||||
default:
|
||||
doc.Blocks = blocks
|
||||
doc.Meta.Confidence = 0.92
|
||||
}
|
||||
@@ -52,6 +56,35 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
|
||||
doc.Blocks = blocks
|
||||
doc.Meta.Confidence = 0.85
|
||||
}
|
||||
case "xlsx", "xlsm":
|
||||
doc.Meta.Parser = "builtin:xlsx"
|
||||
blocks, warns := parseXlsx(content)
|
||||
doc.Blocks = blocks
|
||||
doc.Warnings = append(doc.Warnings, warns...)
|
||||
switch {
|
||||
case len(blocks) == 0:
|
||||
doc.Meta.Confidence = 0.3
|
||||
case len(warns) > 0:
|
||||
doc.Meta.Confidence = 0.6 // 超限截断:内容可用但注明不完整
|
||||
default:
|
||||
doc.Meta.Confidence = 0.9
|
||||
}
|
||||
case "pptx", "pptm", "ppsx":
|
||||
doc.Meta.Parser = "builtin:pptx"
|
||||
blocks, warns := parsePptx(content)
|
||||
doc.Blocks = blocks
|
||||
doc.Warnings = append(doc.Warnings, warns...)
|
||||
switch {
|
||||
case len(blocks) == 0:
|
||||
doc.Meta.Confidence = 0.3
|
||||
case len(warns) > 0:
|
||||
doc.Meta.Confidence = 0.6
|
||||
default:
|
||||
doc.Meta.Confidence = 0.9
|
||||
}
|
||||
case "doc", "xls", "ppt":
|
||||
doc.Meta.Parser = "builtin:legacy-office"
|
||||
doc.Warnings = append(doc.Warnings, "旧版二进制格式 ."+ext+" 不解析:请转存为 ."+legacyOfficeTarget(ext)+" 后重传")
|
||||
default:
|
||||
if isImage(ext) {
|
||||
doc.Warnings = append(doc.Warnings, "图片不走文本解析(OCR 为 MinerU 深度档,远期)")
|
||||
@@ -133,22 +166,73 @@ func parsePlain(s string) []core.DocBlock {
|
||||
|
||||
// ---- docx(OOXML:zip 内 word/document.xml)----
|
||||
|
||||
// xpar 是解码后的段落。文字按文档顺序从任意深度的 w:t 收集,w:ins、
|
||||
// w:hyperlink、w:fldSimple、w:sdt 等包裹层内的文字因此不会丢;被删除或
|
||||
// 移走的原文(w:del、w:moveFrom)、域代码(w:instrText)与兼容回退分支
|
||||
// (mc:Fallback,与 mc:Choice 内容重复)不进正文。
|
||||
type xpar struct {
|
||||
Props struct {
|
||||
Style struct {
|
||||
Val string `xml:"val,attr"`
|
||||
} `xml:"pStyle"`
|
||||
} `xml:"pPr"`
|
||||
Runs []xrun `xml:"r"`
|
||||
Style string
|
||||
Text string
|
||||
Revised bool // 段内含修订标记(插入/删除/移动)
|
||||
}
|
||||
|
||||
type xrun struct {
|
||||
Text []struct {
|
||||
Content string `xml:",chardata"`
|
||||
} `xml:"t"`
|
||||
Break struct {
|
||||
Val string `xml:"val,attr"`
|
||||
} `xml:"br"`
|
||||
// docxSkipText 子树里的文字不属于当前正文。
|
||||
var docxSkipText = map[string]bool{
|
||||
"del": true, "moveFrom": true, "instrText": true, "delInstrText": true, "Fallback": true,
|
||||
}
|
||||
|
||||
// docxRevision 出现即说明文档带修订。
|
||||
var docxRevision = map[string]bool{"ins": true, "del": true, "moveFrom": true, "moveTo": true}
|
||||
|
||||
// UnmarshalXML 按 token 流遍历段落子树。按固定层级(p/r/t)映射只认 w:p
|
||||
// 的直接子级 w:r,修订插入、超链接与域结果里的文字会被静默跳过。
|
||||
func (p *xpar) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var sb strings.Builder
|
||||
skip := 0
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
name := t.Name.Local
|
||||
if docxRevision[name] {
|
||||
p.Revised = true
|
||||
}
|
||||
switch {
|
||||
case docxSkipText[name]:
|
||||
skip++
|
||||
case skip > 0:
|
||||
case name == "pStyle" && p.Style == "":
|
||||
p.Style = attrAnyNS(t, "val")
|
||||
case name == "t":
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
sb.WriteString(s)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if docxSkipText[t.Name.Local] && skip > 0 {
|
||||
skip--
|
||||
}
|
||||
if t.Name == start.Name {
|
||||
p.Text = sb.String()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attrAnyNS 按本地名取属性值(w:val 与无前缀 val 都算)。
|
||||
func attrAnyNS(st xml.StartElement, name string) string {
|
||||
for _, a := range st.Attr {
|
||||
if a.Name.Local == name {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type xtbl struct {
|
||||
@@ -164,97 +248,108 @@ type xtc struct {
|
||||
}
|
||||
|
||||
func parseDocx(content []byte) ([]core.DocBlock, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
blocks, _, err := parseDocxRevised(content)
|
||||
return blocks, err
|
||||
}
|
||||
|
||||
// parseDocxRevised 同 parseDocx,另报告文档是否带修订标记。
|
||||
func parseDocxRevised(content []byte) ([]core.DocBlock, bool, error) {
|
||||
zr, err := openZip(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open zip: %w", err)
|
||||
return nil, false, fmt.Errorf("open zip: %w", err)
|
||||
}
|
||||
var docXML []byte
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docXML, _ = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
if docXML == nil {
|
||||
return nil, fmt.Errorf("word/document.xml not found")
|
||||
docXML, err := zipPart(zr, "word/document.xml")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return parseDocxBody(docXML)
|
||||
}
|
||||
|
||||
// docxRevisionWarning 文档带修订时的显式告警(SR-A5:不静默)。
|
||||
const docxRevisionWarning = "docx 含修订标记:正文按「接受全部修订」提取(插入保留、删除不计入);审阅修订过程请查看原件"
|
||||
|
||||
// parseDocxBody 按文档顺序解码 body 的段落与表格:token 流上遇到 p/tbl 就地
|
||||
// DecodeElement(整元素消费,单元格内嵌 p 不会重复入列),表格与前后段落保持原位。
|
||||
func parseDocxBody(docXML []byte) ([]core.DocBlock, error) {
|
||||
// 段落可能包在 w:ins、w:sdt 等容器里,所以任意深度的 p 与 tbl 都识别;
|
||||
// 返回值 revised 报告是否遇到修订标记。
|
||||
func parseDocxBody(docXML []byte) (blocks []core.DocBlock, revised bool, err error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(docXML))
|
||||
var blocks []core.DocBlock
|
||||
skip := 0
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode document.xml: %w", err)
|
||||
return nil, revised, fmt.Errorf("decode document.xml: %w", err)
|
||||
}
|
||||
if end, ok := tok.(xml.EndElement); ok {
|
||||
if docxSkipText[end.Name.Local] && skip > 0 {
|
||||
skip--
|
||||
}
|
||||
continue
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch start.Name.Local {
|
||||
name := start.Name.Local
|
||||
if docxRevision[name] {
|
||||
revised = true
|
||||
}
|
||||
if docxSkipText[name] {
|
||||
skip++
|
||||
continue
|
||||
}
|
||||
if skip > 0 {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "p":
|
||||
var p xpar
|
||||
if err := dec.DecodeElement(&p, &start); err != nil {
|
||||
return nil, fmt.Errorf("decode paragraph: %w", err)
|
||||
return nil, revised, fmt.Errorf("decode paragraph: %w", err)
|
||||
}
|
||||
text := paragraphText(p)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
revised = revised || p.Revised
|
||||
if strings.TrimSpace(p.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if lvl, ok := headingLevel(p.Props.Style.Val); ok {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text})
|
||||
if lvl, ok := headingLevel(p.Style); ok {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: p.Text})
|
||||
} else {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text})
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: p.Text})
|
||||
}
|
||||
case "tbl":
|
||||
var tbl xtbl
|
||||
if err := dec.DecodeElement(&tbl, &start); err != nil {
|
||||
return nil, fmt.Errorf("decode table: %w", err)
|
||||
return nil, revised, fmt.Errorf("decode table: %w", err)
|
||||
}
|
||||
if rows := tableRows(tbl); len(rows) > 0 {
|
||||
rows, tblRevised := tableRows(tbl)
|
||||
revised = revised || tblRevised
|
||||
if len(rows) > 0 {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks, nil
|
||||
return blocks, revised, nil
|
||||
}
|
||||
|
||||
func tableRows(tbl xtbl) [][]string {
|
||||
func tableRows(tbl xtbl) ([][]string, bool) {
|
||||
var rows [][]string
|
||||
revised := false
|
||||
for _, tr := range tbl.Rows {
|
||||
row := []string{}
|
||||
for _, tc := range tr.Cells {
|
||||
cell := []string{}
|
||||
for _, p := range tc.Paragraphs {
|
||||
cell = append(cell, paragraphText(p))
|
||||
revised = revised || p.Revised
|
||||
cell = append(cell, p.Text)
|
||||
}
|
||||
row = append(row, strings.Join(cell, " "))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func paragraphText(p xpar) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range p.Runs {
|
||||
for _, t := range r.Text {
|
||||
sb.WriteString(t.Content)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
return rows, revised
|
||||
}
|
||||
|
||||
func headingLevel(style string) (int, bool) {
|
||||
@@ -322,13 +417,29 @@ func isMostlyPrintable(s string) bool {
|
||||
return nonPrint*10 < len(s)
|
||||
}
|
||||
|
||||
// legacyOfficeTarget 旧版二进制 Office 格式的现代等价格式(提示转存用)。
|
||||
func legacyOfficeTarget(ext string) string {
|
||||
switch ext {
|
||||
case "doc":
|
||||
return "docx"
|
||||
case "xls":
|
||||
return "xlsx"
|
||||
case "ppt":
|
||||
return "pptx"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
// ParsersDescription 返回解析器注册表描述(来源标记;设置页/文档用)。
|
||||
func ParsersDescription() []core.ParserDesc {
|
||||
return []core.ParserDesc{
|
||||
{ID: "builtin:md", Formats: []string{"md", "markdown"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:txt", Formats: []string{"txt", "csv", "log"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:txt", Formats: []string{"txt", "text", "csv", "log"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:docx", Formats: []string{"docx"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:pdf", Formats: []string{"pdf"}, Source: core.SourceBuiltin, Note: "文本层;扫描件为远期 MinerU 深度档"},
|
||||
{ID: "builtin:xlsx", Formats: []string{"xlsx", "xlsm"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:pptx", Formats: []string{"pptx", "pptm", "ppsx"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:legacy-office", Formats: []string{"doc", "xls", "ppt"}, Source: core.SourceBuiltin, Note: "不解析;提示转存现代格式后重传"},
|
||||
{ID: "mineru", Formats: []string{"pdf", "png", "jpg"}, Source: core.SourcePlugin, Note: "远期可选:深度解析与交叉验证(默认不部署)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,68 @@ func TestParseDocx(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// docxWith 把 body 片段包成最小 docx(zip 内仅 word/document.xml)。
|
||||
func docxWith(t *testing.T, body string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
f, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Write([]byte(`<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>` + body + `</w:body></w:document>`))
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// 修订与超链接:插入/超链接/域结果内的文字必须保留;删除文字不进正文;
|
||||
// 含修订时显式告警(SR-A5:禁止静默降级)。
|
||||
func TestParseDocxTrackedChangesAndHyperlinks(t *testing.T) {
|
||||
body := `<w:p><w:r><w:t>周期</w:t></w:r><w:ins w:id="1" w:author="a"><w:r><w:t>18</w:t></w:r></w:ins>` +
|
||||
`<w:del w:id="2" w:author="a"><w:r><w:delText>12</w:delText></w:r></w:del><w:r><w:t>个月</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>见</w:t></w:r><w:hyperlink r:id="rId5"><w:r><w:t>附件A</w:t></w:r></w:hyperlink></w:p>` +
|
||||
`<w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>3</w:t></w:r></w:fldSimple><w:r><w:t>页</w:t></w:r></w:p>` +
|
||||
`<w:tbl><w:tr><w:tc><w:p><w:ins w:id="3" w:author="a"><w:r><w:t>新增项</w:t></w:r></w:ins></w:p></w:tc></w:tr></w:tbl>`
|
||||
doc := Parse("修订稿.docx", "", docxWith(t, body))
|
||||
var paras []string
|
||||
var cell string
|
||||
for _, b := range doc.Blocks {
|
||||
switch b.Type {
|
||||
case core.BlockPara:
|
||||
paras = append(paras, b.Text)
|
||||
case core.BlockTable:
|
||||
if len(b.Rows) > 0 && len(b.Rows[0]) > 0 {
|
||||
cell = b.Rows[0][0]
|
||||
}
|
||||
}
|
||||
}
|
||||
want := []string{"周期18个月", "见附件A", "3页"}
|
||||
if strings.Join(paras, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("paragraphs: got %q want %q", paras, want)
|
||||
}
|
||||
if cell != "新增项" {
|
||||
t.Errorf("table cell inside w:ins: got %q", cell)
|
||||
}
|
||||
if strings.Contains(doc.PlainText(), "12个月") {
|
||||
t.Errorf("deleted text leaked into body: %q", doc.PlainText())
|
||||
}
|
||||
warned := false
|
||||
for _, w := range doc.Warnings {
|
||||
warned = warned || strings.Contains(w, "修订")
|
||||
}
|
||||
if !warned {
|
||||
t.Errorf("tracked changes must be reported in warnings, got %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// 无修订的普通文档不产生修订告警(避免噪音)。
|
||||
func TestParseDocxNoRevisionNoWarning(t *testing.T) {
|
||||
doc := Parse("干净.docx", "", docxWith(t, `<w:p><w:r><w:t>正文</w:t></w:r></w:p>`))
|
||||
if len(doc.Warnings) != 0 {
|
||||
t.Errorf("clean docx warnings: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePDFInvalid(t *testing.T) {
|
||||
doc := Parse("scan.pdf", "application/pdf", []byte("not a pdf"))
|
||||
if len(doc.Warnings) == 0 || doc.Meta.Confidence > 0.5 {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package ingest
|
||||
|
||||
// OOXML 公共件:zip 部件读取(解压上限防 zip 炸弹)、关系部件解析、
|
||||
// r:id 属性与相对路径归一。docx/xlsx/pptx 解析器共用;纯标准库。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ooxmlPartLimit 单个 zip 部件的解压上限(测试可覆盖)。
|
||||
var ooxmlPartLimit int64 = 64 << 20
|
||||
|
||||
func openZip(content []byte) (*zip.Reader, error) {
|
||||
return zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
}
|
||||
|
||||
// zipPart 按名称读取部件;解压超过 ooxmlPartLimit 视为异常拒绝。
|
||||
func zipPart(zr *zip.Reader, name string) ([]byte, error) {
|
||||
for _, f := range zr.File {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(rc, ooxmlPartLimit+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > ooxmlPartLimit {
|
||||
return nil, fmt.Errorf("part %s exceeds %d bytes decompression limit", name, ooxmlPartLimit)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, fmt.Errorf("part %s not found", name)
|
||||
}
|
||||
|
||||
// relsPath 计算部件对应的关系部件名(xl/workbook.xml → xl/_rels/workbook.xml.rels)。
|
||||
func relsPath(part string) string {
|
||||
dir, base := path.Split(part)
|
||||
return dir + "_rels/" + base + ".rels"
|
||||
}
|
||||
|
||||
type ooxmlRel struct {
|
||||
ID, Target, Type string
|
||||
}
|
||||
|
||||
// parseRels 解析部件的关系表;无关系部件返回空表(不是错误)。
|
||||
func parseRels(zr *zip.Reader, part string) (map[string]ooxmlRel, error) {
|
||||
data, err := zipPart(zr, relsPath(part))
|
||||
if err != nil {
|
||||
return map[string]ooxmlRel{}, nil
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
out := map[string]ooxmlRel{}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "Relationship" {
|
||||
continue
|
||||
}
|
||||
r := ooxmlRel{}
|
||||
for _, a := range st.Attr {
|
||||
switch a.Name.Local {
|
||||
case "Id":
|
||||
r.ID = a.Value
|
||||
case "Target":
|
||||
r.Target = a.Value
|
||||
case "Type":
|
||||
r.Type = a.Value
|
||||
}
|
||||
}
|
||||
if r.ID != "" {
|
||||
out[r.ID] = r
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveRelTarget 把关系 target 解析为 zip 部件名:绝对 target(/xl/…)去
|
||||
// 前导斜杠;相对 target 相对 partDir;归一化 ../ 与 ./ 段。
|
||||
func resolveRelTarget(partDir, target string) string {
|
||||
t := target
|
||||
if !strings.HasPrefix(target, "/") {
|
||||
t = partDir + "/" + target
|
||||
}
|
||||
var seg []string
|
||||
for _, s := range strings.Split(t, "/") {
|
||||
switch s {
|
||||
case "", ".":
|
||||
case "..":
|
||||
if len(seg) > 0 {
|
||||
seg = seg[:len(seg)-1]
|
||||
}
|
||||
default:
|
||||
seg = append(seg, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(seg, "/")
|
||||
}
|
||||
|
||||
// ridAttr 取 r:id:局部名 id 且带命名空间前缀(Transitional 与 Strict OOXML
|
||||
// 的关系引用都满足该形态;无前缀的 id 是别的编号,不误取)。
|
||||
func ridAttr(st xml.StartElement) string {
|
||||
for _, a := range st.Attr {
|
||||
if a.Name.Local == "id" && a.Name.Space != "" {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// attrLocal 取无命名空间前缀的属性值。
|
||||
func attrLocal(st xml.StartElement, name string) string {
|
||||
for _, a := range st.Attr {
|
||||
if a.Name.Local == name && a.Name.Space == "" {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// relIsType 判断关系类型是否以标准关系类型后缀结尾(/worksheet、/slide 等)。
|
||||
func relIsType(rel ooxmlRel, suffix string) bool {
|
||||
return strings.HasSuffix(rel.Type, suffix)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package ingest
|
||||
|
||||
// 真实生成器轮测:openpyxl / python-pptx 现场生成 xlsx/pptx 再解析,防止
|
||||
// 手工夹具只验证"自己写的 XML"。生成器缺失时 t.Skip(诚实出口);CI 已装
|
||||
// 这两个包(.gitea/workflows/ci.yaml 安装 python-docx python-pptx openpyxl)。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func pythonModuleAvailable(t *testing.T, module string) bool {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
return false
|
||||
}
|
||||
return exec.Command("python3", "-c", "import "+module).Run() == nil
|
||||
}
|
||||
|
||||
// runPythonScript 把脚本写到临时目录执行;失败即 t.Fatal(脚本属测试自身)。
|
||||
func runPythonScript(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gen.py")
|
||||
if err := os.WriteFile(path, []byte(script), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command("python3", path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("python generator failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxFromOpenpyxl(t *testing.T) {
|
||||
if !pythonModuleAvailable(t, "openpyxl") {
|
||||
t.Skip("python3 openpyxl not available")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "报价单.xlsx")
|
||||
runPythonScript(t, `
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "报价"
|
||||
ws.append(["物料", "单价", "日期"])
|
||||
ws.append(["支架", 12.5, datetime(2024, 1, 1)])
|
||||
ws["C2"].number_format = "yyyy-mm-dd"
|
||||
ws["D2"] = "=A2&\"-x\""
|
||||
ws["D3"] = True
|
||||
wb.create_sheet("说明").append(["备注行"])
|
||||
wb.save(r"`+file+`")
|
||||
`)
|
||||
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := Parse(file, "", data)
|
||||
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
var heads []string
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if len(heads) != 2 || heads[0] != "报价" || heads[1] != "说明" {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
plain := doc.PlainText()
|
||||
for _, want := range []string{"物料", "支架", "12.5", "2024-01-01"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("missing %q in: %s", want, plain)
|
||||
}
|
||||
}
|
||||
// openpyxl 公式不带缓存值:回显公式本身(可检索、不冒充结果)。
|
||||
if !strings.Contains(plain, "=A2") {
|
||||
t.Errorf("formula without cache should echo: %s", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "TRUE") {
|
||||
t.Errorf("boolean cell should render TRUE: %s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePptxFromPythonPptx(t *testing.T) {
|
||||
if !pythonModuleAvailable(t, "pptx") {
|
||||
t.Skip("python3 pptx not available")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "方案评审.pptx")
|
||||
runPythonScript(t, `
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
prs = Presentation()
|
||||
s1 = prs.slides.add_slide(prs.slide_layouts[0])
|
||||
s1.shapes.title.text = "评审结论"
|
||||
s1.placeholders[1].text = "通过"
|
||||
s2 = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
s2.shapes.title.text = "报价明细"
|
||||
tbl = s2.shapes.add_table(2, 2, Inches(1), Inches(2), Inches(6), Inches(1)).table
|
||||
tbl.cell(0, 0).text = "物料"
|
||||
tbl.cell(0, 1).text = "单价"
|
||||
tbl.cell(1, 0).text = "支架"
|
||||
tbl.cell(1, 1).text = "12.5"
|
||||
notes = s1.notes_slide.notes_text_frame
|
||||
notes.text = "需要补充法规依据"
|
||||
prs.save(r"`+file+`")
|
||||
`)
|
||||
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := Parse(file, "", data)
|
||||
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
var heads []string
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if len(heads) != 2 || !strings.HasPrefix(heads[0], "幻灯片 1:评审结论") || !strings.HasPrefix(heads[1], "幻灯片 2:报价明细") {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
plain := doc.PlainText()
|
||||
for _, want := range []string{"通过", "物料", "支架", "12.5", "备注:需要补充法规依据"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("missing %q in: %s", want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package ingest
|
||||
|
||||
// OOXML(xlsx/pptx)解析器测试:手工构造最小 zip 部件,覆盖顺序解析、富文本、
|
||||
// 公式、日期、稀疏列、隐藏表、组合形状、AlternateContent、表格合并、备注、
|
||||
// 上限与失败路径。真实生成器的轮测见 ooxml_python_test.go。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// zipBytes 按名称构造 zip(键排序保证确定性)。
|
||||
func zipBytes(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
w, err := zw.Create(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(files[n])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
const relTypeNS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
|
||||
func xlsxFixture() map[string]string {
|
||||
return map[string]string{
|
||||
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="` + relTypeNS + `">
|
||||
<sheets>
|
||||
<sheet name="报价" sheetId="1" r:id="rId1"/>
|
||||
<sheet name="草稿" sheetId="2" state="hidden" r:id="rId2"/>
|
||||
</sheets></workbook>`,
|
||||
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="` + relTypeNS + `/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="` + relTypeNS + `/worksheet" Target="/xl/worksheets/sheet2.xml"/>
|
||||
</Relationships>`,
|
||||
// shared[0]=物料;shared[1]=富文本"单价"(rPh 注音必须排除)。
|
||||
"xl/sharedStrings.xml": `<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<si><t>物料</t></si>
|
||||
<si><rPh sb="0" eb="2"><t>たんか</t></rPh><r><rPr lang="ja"/><t>单价</t></r></si>
|
||||
</sst>`,
|
||||
"xl/styles.xml": `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<numFmts><numFmt numFmtId="164" formatCode="yyyy"年"m"月"d"日""/></numFmts>
|
||||
<cellXfs count="3">
|
||||
<xf numFmtId="0"/>
|
||||
<xf numFmtId="14" applyNumberFormat="1"/>
|
||||
<xf numFmtId="164" applyNumberFormat="1"/>
|
||||
</cellXfs></styleSheet>`,
|
||||
// A1=物料 B1=单价(富文本) A2=公式缓存 10 B2=无缓存公式 C2=日期(内置14)=2024-01-01
|
||||
// D2=TRUE E2=#DIV/0! F2=内联 G2=自定义日期格式(2024-01-02) 稀疏行 C4=物料。
|
||||
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData>
|
||||
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>
|
||||
<row r="2"><c r="A2"><f>B9*2</f><v>10</v></c><c r="B2"><f>B9*3</f></c><c r="C2" s="1"><v>45292</v></c><c r="D2" t="b"><v>1</v></c><c r="E2" t="e"><v>#DIV/0!</v></c><c r="F2" t="inlineStr"><is><t>内联</t></is></c><c r="G2" s="2"><v>45293</v></c></row>
|
||||
<row r="4"><c r="C4" t="s"><v>0</v></c></row>
|
||||
</sheetData></worksheet>`,
|
||||
"xl/worksheets/sheet2.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData><row r="1"><c r="A1"><v>7</v></c></row></sheetData></worksheet>`,
|
||||
}
|
||||
}
|
||||
|
||||
func findTable(doc core.ParsedDocument, after int) *core.DocBlock {
|
||||
for i := after; i < len(doc.Blocks); i++ {
|
||||
if doc.Blocks[i].Type == core.BlockTable {
|
||||
return &doc.Blocks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestParseXlsxBuiltFromParts(t *testing.T) {
|
||||
doc := Parse("报价单.xlsx", "", zipBytes(t, xlsxFixture()))
|
||||
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
if doc.Meta.Confidence != 0.9 {
|
||||
t.Fatalf("confidence: %v", doc.Meta.Confidence)
|
||||
}
|
||||
// 工作表顺序取 workbook.xml(报价在前),隐藏表标注;绝对 target /xl/… 也能定位。
|
||||
if doc.Blocks[0].Type != core.BlockHeading || doc.Blocks[0].Text != "报价" {
|
||||
t.Fatalf("first block: %+v", doc.Blocks[0])
|
||||
}
|
||||
heads := []string{}
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
tbl := findTable(doc, 0)
|
||||
if tbl == nil || len(tbl.Rows) != 3 {
|
||||
t.Fatalf("table: %+v", tbl)
|
||||
}
|
||||
row1, row2, row4 := tbl.Rows[0], tbl.Rows[1], tbl.Rows[2]
|
||||
if row1[0] != "物料" || row1[1] != "单价" {
|
||||
t.Errorf("header row (phonetic must be excluded): %v", row1)
|
||||
}
|
||||
want := []string{"10", "=B9*3", "2024-01-01", "TRUE", "#DIV/0!", "内联", "2024-01-02"}
|
||||
if len(row2) != len(want) {
|
||||
t.Fatalf("row2: %v", row2)
|
||||
}
|
||||
for i, w := range want {
|
||||
if row2[i] != w {
|
||||
t.Errorf("row2[%d]: got %q want %q", i, row2[i], w)
|
||||
}
|
||||
}
|
||||
// 稀疏单元格按引用就位:C4 → 第三列。
|
||||
if len(row4) != 3 || row4[0] != "" || row4[1] != "" || row4[2] != "物料" {
|
||||
t.Errorf("sparse row: %v", row4)
|
||||
}
|
||||
if !strings.Contains(doc.PlainText(), "2024-01-01") {
|
||||
t.Error("PlainText missing date text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxEmptySheetSkipped(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
files["xl/worksheets/sheet2.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"/></sheetData></worksheet>`
|
||||
doc := Parse("x.xlsx", "", zipBytes(t, files))
|
||||
heads := 0
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads++
|
||||
}
|
||||
}
|
||||
if heads != 1 || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("empty sheet must be skipped: blocks=%+v warnings=%v", doc.Blocks, doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxInvalid(t *testing.T) {
|
||||
doc := Parse("坏.xlsx", "", []byte("not a zip"))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
|
||||
t.Fatalf("invalid xlsx should fail visibly: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
files := xlsxFixture()
|
||||
delete(files, "xl/workbook.xml")
|
||||
doc = Parse("无工作簿.xlsx", "", zipBytes(t, files))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
|
||||
t.Fatalf("missing workbook.xml should fail visibly: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxCellCap(t *testing.T) {
|
||||
old := xlsxMaxCells
|
||||
xlsxMaxCells = 3
|
||||
defer func() { xlsxMaxCells = old }()
|
||||
doc := Parse("超限.xlsx", "", zipBytes(t, xlsxFixture()))
|
||||
if len(doc.Blocks) == 0 {
|
||||
t.Fatal("truncated parse should keep partial blocks")
|
||||
}
|
||||
if len(doc.Warnings) == 0 || doc.Meta.Confidence != 0.6 {
|
||||
t.Fatalf("truncation must warn and lower confidence: %v %v", doc.Meta.Confidence, doc.Warnings)
|
||||
}
|
||||
if !strings.Contains(strings.Join(doc.Warnings, ";"), "上限") {
|
||||
t.Errorf("warnings: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxPartSizeCap(t *testing.T) {
|
||||
old := ooxmlPartLimit
|
||||
ooxmlPartLimit = 16
|
||||
defer func() { ooxmlPartLimit = old }()
|
||||
doc := Parse("炸弹.xlsx", "", zipBytes(t, xlsxFixture()))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
|
||||
t.Fatalf("oversized part must fail visibly: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxDate1904(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
files["xl/workbook.xml"] = strings.Replace(files["xl/workbook.xml"],
|
||||
"<sheets>", `<workbookPr date1904="1"/><sheets>`, 1)
|
||||
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData><row r="1"><c r="A1" s="1"><v>1</v></c><c r="B1" s="1"><v>0.520833333333333</v></c></row></sheetData></worksheet>`
|
||||
doc := Parse("mac.xlsx", "", zipBytes(t, files))
|
||||
tbl := findTable(doc, 0)
|
||||
// 1904 历法:serial 0 = 1904-01-01,serial 1 = 1904-01-02。
|
||||
if tbl == nil || tbl.Rows[0][0] != "1904-01-02" {
|
||||
t.Fatalf("1904 epoch: %+v", tbl)
|
||||
}
|
||||
if !strings.Contains(tbl.Rows[0][1], "1904-01-01 12:30") {
|
||||
t.Errorf("time fraction: %q", tbl.Rows[0][1])
|
||||
}
|
||||
}
|
||||
|
||||
const pNS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
const aNS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
|
||||
func sp(phType, text string) string {
|
||||
ph := ""
|
||||
if phType != "" {
|
||||
ph = `<p:nvPr><p:ph type="` + phType + `"/></p:nvPr>`
|
||||
} else {
|
||||
ph = `<p:nvPr/>`
|
||||
}
|
||||
return `<p:sp><p:nvSpPr><p:cNvPr id="2" name="S"/><p:cNvSpPr/></p:nvSpPr>` + ph + `<p:spPr/>` +
|
||||
`<p:txBody><a:bodyPr/><a:p><a:r><a:t>` + text + `</a:t></a:r></a:p></p:txBody></p:sp>`
|
||||
}
|
||||
|
||||
func pptxFixture() map[string]string {
|
||||
presRel := func(id, target string) string {
|
||||
return `<Relationship Id="` + id + `" Type="` + relTypeNS + `/slide" Target="` + target + `"/>`
|
||||
}
|
||||
slide1 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `" xmlns:r="` + relTypeNS + `"><p:cSld><p:spTree>
|
||||
<p:nvGrpSpPr/><p:grpSpPr/>
|
||||
` + sp("title", "封面标题") + `
|
||||
` + sp("body", "") + `
|
||||
<p:sp><p:nvSpPr><p:cNvPr id="4" name="B"/><p:cNvSpPr/></p:nvSpPr><p:nvPr/><p:spPr/><p:txBody><a:bodyPr/>
|
||||
<a:p><a:r><a:t>第一行</a:t></a:r><a:br/><a:r><a:t>第二行</a:t></a:r></a:p></p:txBody></p:sp>
|
||||
<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>` + sp("", "组合内文本") + `</p:grpSp>
|
||||
<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
|
||||
<mc:Choice Requires="p14">` + sp("", "选内文本") + `</mc:Choice>
|
||||
<mc:Fallback>` + sp("", "弃内文本") + `</mc:Fallback>
|
||||
</mc:AlternateContent>
|
||||
<p:sp><p:nvSpPr><p:cNvPr id="6" name="N"/><p:cNvSpPr/></p:nvSpPr><p:nvPr><p:ph type="sldNum"/></p:nvPr><p:spPr/><p:txBody><a:p><a:fld id="{F1}"><a:t>3</a:t></a:fld></a:p></p:txBody></p:sp>
|
||||
<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm/><a:graphic><a:graphicData uri="` + aNS + `/table"><a:tbl>
|
||||
<a:tr h="1"><a:tc gridSpan="2"><a:txBody><a:p><a:r><a:t>合并头</a:t></a:r></a:p></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>列三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
|
||||
<a:tr h="1"><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>值三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
|
||||
</a:tbl></a:graphicData></a:graphic></p:graphicFrame>
|
||||
</p:spTree></p:cSld></p:sld>`
|
||||
slide2 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
|
||||
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "第二页要点") + `
|
||||
</p:spTree></p:cSld></p:sld>`
|
||||
return map[string]string{
|
||||
"ppt/presentation.xml": `<p:presentation xmlns:p="` + pNS + `" xmlns:r="` + relTypeNS + `"><p:sldIdLst>
|
||||
<p:sldId id="256" r:id="rIdA"/><p:sldId id="257" r:id="rIdB"/>
|
||||
</p:sldIdLst></p:presentation>`,
|
||||
"ppt/_rels/presentation.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
presRel("rIdA", "slides/slide1.xml") + presRel("rIdB", "slides/slide2.xml") + `</Relationships>`,
|
||||
"ppt/slides/slide1.xml": slide1,
|
||||
"ppt/slides/slide2.xml": slide2,
|
||||
"ppt/slides/_rels/slide1.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="` + relTypeNS + `/notesSlide" Target="../notesSlides/notesSlide1.xml"/></Relationships>`,
|
||||
// slide2 的备注故意缺失(无 rels 文件)。
|
||||
"ppt/notesSlides/notesSlide1.xml": `<p:notes xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
|
||||
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "补充说明") + `
|
||||
</p:spTree></p:cSld></p:notes>`,
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePptxBuiltFromParts(t *testing.T) {
|
||||
doc := Parse("演示.pptx", "", zipBytes(t, pptxFixture()))
|
||||
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 || doc.Meta.Confidence != 0.9 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
var heads []string
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
// sldIdLst 顺序:slide1(有标题)在前;slide2 无标题占位符 → 无标题兜底。
|
||||
if len(heads) != 2 || heads[0] != "幻灯片 1:封面标题" || heads[1] != "幻灯片 2" {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
plain := doc.PlainText()
|
||||
for _, want := range []string{"第一行\n第二行", "组合内文本", "选内文本", "备注:补充说明"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("missing %q in plain text", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "弃内文本") {
|
||||
t.Error("mc:Fallback content must be skipped")
|
||||
}
|
||||
// 页码占位符与字段文本不出现。
|
||||
if strings.Contains(plain, "\n3\n") {
|
||||
t.Error("slide number placeholder text leaked")
|
||||
}
|
||||
// 表格:gridSpan 占位 + hMerge 续格留空,保持三列对齐。
|
||||
tbl := findTable(doc, 0)
|
||||
if tbl == nil || len(tbl.Rows) != 2 {
|
||||
t.Fatalf("table: %+v", tbl)
|
||||
}
|
||||
if strings.Join(tbl.Rows[0], "|") != "合并头||列三" {
|
||||
t.Errorf("row1: %v", tbl.Rows[0])
|
||||
}
|
||||
if strings.Join(tbl.Rows[1], "|") != "||值三" {
|
||||
t.Errorf("row2: %v", tbl.Rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePptxInvalidAndEmpty(t *testing.T) {
|
||||
doc := Parse("坏.pptx", "", []byte("garbage"))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
|
||||
t.Fatalf("invalid pptx: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
files := pptxFixture()
|
||||
files["ppt/presentation.xml"] = `<p:presentation xmlns:p="` + pNS + `"></p:presentation>`
|
||||
doc = Parse("空演示.pptx", "", zipBytes(t, files))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
|
||||
t.Fatalf("no slides: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLegacyOfficeHint(t *testing.T) {
|
||||
for ext, want := range map[string]string{
|
||||
"doc": "docx", "xls": "xlsx", "ppt": "pptx",
|
||||
} {
|
||||
doc := Parse("旧档."+ext, "", []byte{0xD0, 0xCF})
|
||||
if doc.Meta.Parser != "builtin:legacy-office" {
|
||||
t.Fatalf("%s parser: %+v", ext, doc.Meta)
|
||||
}
|
||||
if len(doc.Blocks) != 0 || len(doc.Warnings) != 1 {
|
||||
t.Fatalf("%s blocks/warnings: %+v %v", ext, doc.Blocks, doc.Warnings)
|
||||
}
|
||||
if !strings.Contains(doc.Warnings[0], want) {
|
||||
t.Errorf("%s hint should mention %s: %v", ext, want, doc.Warnings[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册表与分派行为一致:每个已注册格式的解析器 ID 与 Parse 分派结果一致,
|
||||
// 且 builtin 解析器覆盖的扩展名都出现在注册表里。
|
||||
func TestParsersDescriptionMatchesDispatch(t *testing.T) {
|
||||
byFormat := map[string]string{}
|
||||
for _, d := range ParsersDescription() {
|
||||
if d.Source != core.SourceBuiltin {
|
||||
continue
|
||||
}
|
||||
for _, f := range d.Formats {
|
||||
byFormat[f] = d.ID
|
||||
}
|
||||
}
|
||||
check := map[string]string{
|
||||
"md": "builtin:md", "markdown": "builtin:md",
|
||||
"txt": "builtin:txt", "text": "builtin:txt", "csv": "builtin:txt", "log": "builtin:txt",
|
||||
"docx": "builtin:docx", "pdf": "builtin:pdf",
|
||||
"xlsx": "builtin:xlsx", "xlsm": "builtin:xlsx",
|
||||
"pptx": "builtin:pptx", "pptm": "builtin:pptx", "ppsx": "builtin:pptx",
|
||||
"doc": "builtin:legacy-office", "xls": "builtin:legacy-office", "ppt": "builtin:legacy-office",
|
||||
}
|
||||
for ext, wantID := range check {
|
||||
got, ok := byFormat[ext]
|
||||
if !ok {
|
||||
t.Errorf("format .%s missing from registry", ext)
|
||||
continue
|
||||
}
|
||||
doc := Parse("a."+ext, "", []byte("x"))
|
||||
if doc.Meta.Parser != wantID || got != wantID {
|
||||
t.Errorf(".%s: registry=%s dispatch=%s want=%s", ext, got, doc.Meta.Parser, wantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseXlsxWideColumnSkipsCellNotSheets:稀疏样式单元格落在远端列(XFD 区,
|
||||
// 列序 ≥1024)只跳过该格并告警——不误报单元格上限、不丢弃后续工作表。
|
||||
func TestParseXlsxWideColumnSkipsCellNotSheets(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
// 第一表加一个列引用 XFD1(16384 列)的样式单元格;第二表(隐藏的"草稿")必须照常解析。
|
||||
files["xl/worksheets/sheet1.xml"] = strings.Replace(files["xl/worksheets/sheet1.xml"],
|
||||
"</row>\n<row r=\"4\"", "</row>\n<row r=\"3\"><c r=\"XFD3\" t=\"inlineStr\"><is><t>边角样式</t></is></c></row>\n<row r=\"4\"", 1)
|
||||
doc := Parse("宽表.xlsx", "", zipBytes(t, files))
|
||||
heads := []string{}
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
|
||||
t.Fatalf("later sheets must survive a wide column: headings=%v warnings=%v", heads, doc.Warnings)
|
||||
}
|
||||
joined := strings.Join(doc.Warnings, ";")
|
||||
if !strings.Contains(joined, "超过 1024 列") {
|
||||
t.Fatalf("wide-column warning missing: %v", doc.Warnings)
|
||||
}
|
||||
if strings.Contains(joined, "单元格总数") {
|
||||
t.Fatalf("cell-cap warning must not fire for a wide column: %v", doc.Warnings)
|
||||
}
|
||||
if doc.Meta.Confidence != 0.6 {
|
||||
t.Fatalf("partial-content confidence expected 0.6: %v", doc.Meta.Confidence)
|
||||
}
|
||||
// 被跳过的远端列内容不得混进表格。
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockTable {
|
||||
for _, row := range b.Rows {
|
||||
for _, cell := range row {
|
||||
if strings.Contains(cell, "边角样式") {
|
||||
t.Fatalf("skipped wide-column cell leaked into table: %v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseXlsxBadSheetXMLWarns:单张工作表 XML 损坏必须显式告警(其余表照常),
|
||||
// 与提案「显式失败,不静默」一致。
|
||||
func TestParseXlsxBadSheetXMLWarns(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row`
|
||||
doc := Parse("坏表.xlsx", "", zipBytes(t, files))
|
||||
if len(doc.Warnings) == 0 {
|
||||
t.Fatal("malformed sheet XML must warn")
|
||||
}
|
||||
joined := strings.Join(doc.Warnings, ";")
|
||||
if !strings.Contains(joined, "工作表 报价") || !strings.Contains(joined, "XML 解码失败") {
|
||||
t.Fatalf("warning should name the sheet and the failure: %v", doc.Warnings)
|
||||
}
|
||||
// 第二张表照常解析。
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading && b.Text == "草稿(隐藏)" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("healthy sheets must still parse: %+v", doc.Blocks)
|
||||
}
|
||||
if doc.Meta.Confidence != 0.6 {
|
||||
t.Fatalf("partial parse confidence expected 0.6: %v", doc.Meta.Confidence)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package ingest
|
||||
|
||||
// pptx/pptm/ppsx(PresentationML)内置解析器。幻灯片顺序取 presentation.xml
|
||||
// 的 sldIdLst;标题占位符(title/ctrTitle)产标题块,正文形状按段落输出
|
||||
//(a:br 换行),组合形状经同一 token 走查天然递归;mc:AlternateContent 只取
|
||||
// Choice 分支;graphicFrame 内的表格按 tr/tc 输出(gridSpan/hMerge 占位保列
|
||||
// 对齐);备注页正文输出「备注:…」;页码/日期/页脚占位符跳过。图片与
|
||||
// SmartArt 不解析。纯标准库。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func parsePptx(content []byte) ([]core.DocBlock, []string) {
|
||||
zr, err := openZip(content)
|
||||
if err != nil {
|
||||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||||
}
|
||||
slides, warns := pptxSlideOrder(zr)
|
||||
if slides == nil && len(warns) > 0 {
|
||||
return nil, warns
|
||||
}
|
||||
var blocks []core.DocBlock
|
||||
for i, sp := range slides {
|
||||
n := i + 1
|
||||
data, err := zipPart(zr, sp)
|
||||
if err != nil {
|
||||
warns = append(warns, fmt.Sprintf("幻灯片 %d 读取失败:%v", n, err))
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: fmt.Sprintf("幻灯片 %d", n)})
|
||||
continue
|
||||
}
|
||||
title, paras, tables := pptxSlideBlocks(data)
|
||||
heading := fmt.Sprintf("幻灯片 %d", n)
|
||||
if title != "" {
|
||||
heading += ":" + title
|
||||
}
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: heading})
|
||||
blocks = append(blocks, paras...)
|
||||
blocks = append(blocks, tables...)
|
||||
if note := pptxSlideNotes(zr, sp); note != "" {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: "备注:" + note})
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 && len(warns) == 0 {
|
||||
warns = append(warns, "pptx 无可解析的幻灯片内容")
|
||||
}
|
||||
return blocks, warns
|
||||
}
|
||||
|
||||
// pptxSlideOrder 读 presentation.xml 的 sldIdLst → 幻灯片部件名(按顺序)。
|
||||
func pptxSlideOrder(zr *zip.Reader) ([]string, []string) {
|
||||
data, err := zipPart(zr, "ppt/presentation.xml")
|
||||
if err != nil {
|
||||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||||
}
|
||||
rels, err := parseRels(zr, "ppt/presentation.xml")
|
||||
if err != nil {
|
||||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var out, warns []string
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return out, append(warns, "presentation.xml 解码失败:"+err.Error())
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "sldId" {
|
||||
continue
|
||||
}
|
||||
rel, ok := rels[ridAttr(st)]
|
||||
if !ok || !relIsType(rel, "/slide") {
|
||||
warns = append(warns, "幻灯片关系缺失或类型不符")
|
||||
continue
|
||||
}
|
||||
out = append(out, resolveRelTarget("ppt", rel.Target))
|
||||
}
|
||||
return out, warns
|
||||
}
|
||||
|
||||
// pptxSlideBlocks 解析一张幻灯片:标题、正文段落、表格块。
|
||||
// 表格块统一附在段落后(图形框与正文的精确穿插对检索无价值)。
|
||||
func pptxSlideBlocks(data []byte) (title string, paras []core.DocBlock, tables []core.DocBlock) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var stack []string
|
||||
has := func(name string) bool {
|
||||
for _, s := range stack {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
skip := 0 // mc:Fallback 子树深度
|
||||
// 形状状态
|
||||
spDepth := -1
|
||||
phSkip := false
|
||||
isTitle := false
|
||||
var spParas []string
|
||||
var para strings.Builder
|
||||
paraOpen := false
|
||||
// 文本收集目标
|
||||
textInPara := false
|
||||
textInCell := false
|
||||
// 表格状态
|
||||
var tblRows [][]string
|
||||
var tblRow []string
|
||||
var cellBuf strings.Builder
|
||||
cellOpen := false
|
||||
gridSpan := 1
|
||||
cellHM := false
|
||||
|
||||
finishPara := func() {
|
||||
if !paraOpen {
|
||||
return
|
||||
}
|
||||
paraOpen = false
|
||||
if s := strings.TrimRight(para.String(), "\n"); strings.TrimSpace(s) != "" {
|
||||
spParas = append(spParas, strings.TrimSpace(s))
|
||||
}
|
||||
para.Reset()
|
||||
}
|
||||
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", paras, tables
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if skip > 0 {
|
||||
skip++
|
||||
continue
|
||||
}
|
||||
local := t.Name.Local
|
||||
switch local {
|
||||
case "Fallback":
|
||||
if has("AlternateContent") {
|
||||
skip = 1
|
||||
continue
|
||||
}
|
||||
case "sp":
|
||||
spDepth = len(stack)
|
||||
phSkip, isTitle, spParas = false, false, nil
|
||||
case "ph":
|
||||
if spDepth >= 0 && len(stack) > spDepth {
|
||||
switch attrLocal(t, "type") {
|
||||
case "title", "ctrTitle":
|
||||
isTitle = true
|
||||
case "dt", "ftr", "sldNum":
|
||||
phSkip = true
|
||||
}
|
||||
}
|
||||
case "p":
|
||||
if spDepth >= 0 && has("txBody") && !has("tbl") {
|
||||
finishPara()
|
||||
paraOpen = true
|
||||
para.Reset()
|
||||
} else if cellOpen {
|
||||
if cellBuf.Len() > 0 {
|
||||
cellBuf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
case "br":
|
||||
if paraOpen {
|
||||
para.WriteByte('\n')
|
||||
}
|
||||
case "t":
|
||||
if has("fld") {
|
||||
break // 字段(页码/日期)不取
|
||||
}
|
||||
textInPara = paraOpen
|
||||
textInCell = cellOpen && !paraOpen
|
||||
case "tc":
|
||||
if has("tbl") {
|
||||
cellBuf.Reset()
|
||||
cellOpen = true
|
||||
gridSpan, _ = strconv.Atoi(attrLocal(t, "gridSpan"))
|
||||
if gridSpan < 1 {
|
||||
gridSpan = 1
|
||||
}
|
||||
hm := attrLocal(t, "hMerge")
|
||||
cellHM = hm == "1" || strings.EqualFold(hm, "true")
|
||||
}
|
||||
}
|
||||
stack = append(stack, local)
|
||||
case xml.CharData:
|
||||
if textInPara {
|
||||
para.Write(t)
|
||||
} else if textInCell {
|
||||
cellBuf.Write(t)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if skip > 0 {
|
||||
skip--
|
||||
continue
|
||||
}
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
switch t.Name.Local {
|
||||
case "t":
|
||||
textInPara, textInCell = false, false
|
||||
case "p":
|
||||
finishPara()
|
||||
case "sp":
|
||||
if spDepth >= 0 && len(stack) == spDepth {
|
||||
if !phSkip && len(spParas) > 0 {
|
||||
if isTitle && title == "" {
|
||||
title = strings.Join(spParas, " ")
|
||||
} else {
|
||||
for _, p := range spParas {
|
||||
paras = append(paras, core.DocBlock{Type: core.BlockPara, Text: p})
|
||||
}
|
||||
}
|
||||
}
|
||||
spDepth = -1
|
||||
phSkip, isTitle, spParas = false, false, nil
|
||||
}
|
||||
case "tc":
|
||||
if cellOpen {
|
||||
text := strings.TrimSpace(cellBuf.String())
|
||||
if cellHM {
|
||||
text = "" // 横向合并的续格:占位保列对齐
|
||||
}
|
||||
tblRow = append(tblRow, text)
|
||||
for i := 1; i < gridSpan; i++ {
|
||||
tblRow = append(tblRow, "") // 合并占位保列对齐
|
||||
}
|
||||
cellOpen = false
|
||||
}
|
||||
case "tr":
|
||||
if len(tblRow) > 0 {
|
||||
for len(tblRow) > 0 && tblRow[len(tblRow)-1] == "" {
|
||||
tblRow = tblRow[:len(tblRow)-1]
|
||||
}
|
||||
if len(tblRow) > 0 {
|
||||
tblRows = append(tblRows, tblRow)
|
||||
}
|
||||
tblRow = nil
|
||||
}
|
||||
case "tbl":
|
||||
if len(tblRows) > 0 {
|
||||
tables = append(tables, core.DocBlock{Type: core.BlockTable, Rows: tblRows})
|
||||
}
|
||||
tblRows = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
finishPara()
|
||||
return title, paras, tables
|
||||
}
|
||||
|
||||
// pptxSlideNotes 读幻灯片的备注页正文;无备注返回空串。
|
||||
func pptxSlideNotes(zr *zip.Reader, slidePart string) string {
|
||||
rels, err := parseRels(zr, slidePart)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, rel := range rels {
|
||||
if !relIsType(rel, "/notesSlide") {
|
||||
continue
|
||||
}
|
||||
data, err := zipPart(zr, resolveRelTarget(path.Dir(slidePart), rel.Target))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
_, paras, _ := pptxSlideBlocks(data)
|
||||
var lines []string
|
||||
for _, p := range paras {
|
||||
lines = append(lines, p.Text)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package ingest
|
||||
|
||||
// xlsx/xlsm(SpreadsheetML)内置解析器。工作表顺序取 workbook.xml 的 sheets
|
||||
// 列表(target 经关系部件解析,支持相对/绝对路径);共享字符串含富文本 run
|
||||
//(注音 rPh 无对应解码字段,天然排除);单元格覆盖 inlineStr/str/b/e/n,
|
||||
// 公式取缓存值、无缓存回显公式;日期按数字格式(内置 ID 与自定义记号)转
|
||||
// ISO,1900/1904 双日期系统;稀疏单元格按引用就位。纯标准库。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 解析上限(包级变量,测试可覆盖)。
|
||||
var (
|
||||
xlsxMaxCells = 200000
|
||||
xlsxMaxCols = 1024
|
||||
)
|
||||
|
||||
type xlsxSheetRef struct {
|
||||
Name string
|
||||
State string
|
||||
Path string
|
||||
IsSheet bool
|
||||
}
|
||||
|
||||
func parseXlsx(content []byte) ([]core.DocBlock, []string) {
|
||||
zr, err := openZip(content)
|
||||
if err != nil {
|
||||
return nil, []string{"xlsx 解析失败:" + err.Error()}
|
||||
}
|
||||
sheets, date1904, warns := xlsxWorkbookSheets(zr)
|
||||
if len(warns) > 0 && sheets == nil {
|
||||
return nil, warns
|
||||
}
|
||||
shared := xlsxSharedStrings(zr)
|
||||
dateStyles := xlsxDateStyles(zr)
|
||||
var blocks []core.DocBlock
|
||||
cells := 0
|
||||
for _, sh := range sheets {
|
||||
if !sh.IsSheet || sh.Path == "" {
|
||||
continue // chartsheet 等非工作表部件
|
||||
}
|
||||
data, err := zipPart(zr, sh.Path)
|
||||
if err != nil {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s 读取失败:%v", sh.Name, err))
|
||||
continue
|
||||
}
|
||||
table, used, trunc, wide, sheetWarn := xlsxSheetBlocks(data, shared, dateStyles, date1904, cells)
|
||||
cells = used
|
||||
if sheetWarn != "" {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s:%s", sh.Name, sheetWarn))
|
||||
}
|
||||
if wide {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s 含超过 %d 列的稀疏单元格,已跳过该列(其余内容不受影响)", sh.Name, xlsxMaxCols))
|
||||
}
|
||||
if table == nil {
|
||||
continue // 空表(读取失败/坏 XML 已单独告警)
|
||||
}
|
||||
title := sh.Name
|
||||
if sh.State == "hidden" || sh.State == "veryHidden" {
|
||||
title += "(隐藏)"
|
||||
}
|
||||
blocks = append(blocks,
|
||||
core.DocBlock{Type: core.BlockHeading, Level: 2, Text: title},
|
||||
*table,
|
||||
)
|
||||
if trunc {
|
||||
warns = append(warns, fmt.Sprintf("单元格总数超过 %d 上限,工作表 %s 之后未解析", xlsxMaxCells, sh.Name))
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 && len(warns) == 0 {
|
||||
warns = append(warns, "xlsx 无可解析的工作表内容")
|
||||
}
|
||||
return blocks, warns
|
||||
}
|
||||
|
||||
// xlsxWorkbookSheets 读 workbook.xml:工作表顺序、隐藏状态、1904 日期系统。
|
||||
func xlsxWorkbookSheets(zr *zip.Reader) (sheets []xlsxSheetRef, date1904 bool, warns []string) {
|
||||
data, err := zipPart(zr, "xl/workbook.xml")
|
||||
if err != nil {
|
||||
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
|
||||
}
|
||||
rels, err := parseRels(zr, "xl/workbook.xml")
|
||||
if err != nil {
|
||||
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return sheets, date1904, append(warns, "workbook.xml 解码失败:"+err.Error())
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch st.Name.Local {
|
||||
case "workbookPr":
|
||||
if v := attrLocal(st, "date1904"); v == "1" || strings.EqualFold(v, "true") {
|
||||
date1904 = true
|
||||
}
|
||||
case "sheet":
|
||||
ref := xlsxSheetRef{Name: attrLocal(st, "name"), State: attrLocal(st, "state")}
|
||||
if rel, ok := rels[ridAttr(st)]; ok {
|
||||
ref.Path = resolveRelTarget("xl", rel.Target)
|
||||
ref.IsSheet = relIsType(rel, "/worksheet")
|
||||
} else {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s 的关系缺失", ref.Name))
|
||||
}
|
||||
sheets = append(sheets, ref)
|
||||
}
|
||||
}
|
||||
return sheets, date1904, warns
|
||||
}
|
||||
|
||||
// xlsxSharedStrings 读共享字符串表;富文本按 run 拼接,rPh 注音不进结果。
|
||||
func xlsxSharedStrings(zr *zip.Reader) []string {
|
||||
data, err := zipPart(zr, "xl/sharedStrings.xml")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var out []string
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "si" {
|
||||
continue
|
||||
}
|
||||
var si struct {
|
||||
T string `xml:"t"`
|
||||
Runs []struct {
|
||||
T string `xml:"t"`
|
||||
} `xml:"r"`
|
||||
}
|
||||
if err := dec.DecodeElement(&si, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
if si.T != "" || len(si.Runs) == 0 {
|
||||
out = append(out, si.T)
|
||||
continue
|
||||
}
|
||||
parts := make([]string, 0, len(si.Runs))
|
||||
for _, r := range si.Runs {
|
||||
parts = append(parts, r.T)
|
||||
}
|
||||
out = append(out, strings.Join(parts, ""))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// xlsxDateStyles 返回按日期呈现的样式索引集合(cellXfs 下标)。
|
||||
func xlsxDateStyles(zr *zip.Reader) map[int]bool {
|
||||
data, err := zipPart(zr, "xl/styles.xml")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
custom := map[int]string{}
|
||||
date := map[int]bool{}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
inCellXfs := false
|
||||
xfIdx := 0
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch t.Name.Local {
|
||||
case "numFmt":
|
||||
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
|
||||
custom[id] = attrLocal(t, "formatCode")
|
||||
case "cellXfs":
|
||||
inCellXfs = true
|
||||
xfIdx = 0
|
||||
case "xf":
|
||||
if !inCellXfs {
|
||||
continue
|
||||
}
|
||||
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
|
||||
if xlsxFmtIsDate(id, custom) {
|
||||
date[xfIdx] = true
|
||||
}
|
||||
xfIdx++
|
||||
}
|
||||
case xml.EndElement:
|
||||
if t.Name.Local == "cellXfs" {
|
||||
inCellXfs = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
// xlsxFmtIsDate:自定义格式按记号判断;内置 ID 覆盖常见日期/时间区间。
|
||||
func xlsxFmtIsDate(id int, custom map[int]string) bool {
|
||||
if code, ok := custom[id]; ok {
|
||||
return formatCodeIsDate(code)
|
||||
}
|
||||
switch {
|
||||
case id >= 14 && id <= 22, id >= 27 && id <= 36,
|
||||
id >= 45 && id <= 47, id >= 50 && id <= 58:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// formatCodeIsDate 剥掉引号字面量与方括号节([Red]、[DBNum1]、[$-409] 等)后,
|
||||
// 剩余字母含 y/d/h/s/m 记号即按日期处理。
|
||||
func formatCodeIsDate(code string) bool {
|
||||
var b strings.Builder
|
||||
inQuote, inBracket := false, false
|
||||
runes := []rune(code)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
switch {
|
||||
case r == '"':
|
||||
inQuote = !inQuote
|
||||
case inQuote:
|
||||
case r == '[':
|
||||
inBracket = true
|
||||
case r == ']':
|
||||
inBracket = false
|
||||
case inBracket:
|
||||
case r == '\\':
|
||||
i++ // 转义字符:连同下一字符跳过
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.ContainsAny(strings.ToLower(b.String()), "ydhsm")
|
||||
}
|
||||
|
||||
type xlsxCellEl struct {
|
||||
Ref string `xml:"r,attr"`
|
||||
T string `xml:"t,attr"`
|
||||
S string `xml:"s,attr"`
|
||||
IS *struct {
|
||||
T string `xml:"t"`
|
||||
} `xml:"is"`
|
||||
F string `xml:"f"`
|
||||
V string `xml:"v"`
|
||||
}
|
||||
|
||||
type xlsxRowEl struct {
|
||||
Cells []xlsxCellEl `xml:"c"`
|
||||
}
|
||||
|
||||
// xlsxSheetBlocks 解析单张工作表;返回表格块、累计单元格数、是否触达单元格
|
||||
// 上限、是否跳过超宽列、以及坏 XML 的告警(显式失败,不静默)。无内容行
|
||||
// 返回 nil 表。
|
||||
func xlsxSheetBlocks(data []byte, shared []string, dateStyles map[int]bool, date1904 bool, cellsUsed int) (*core.DocBlock, int, bool, bool, string) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var rows [][]string
|
||||
count := cellsUsed
|
||||
truncated := false
|
||||
wide := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, count, truncated, wide, "XML 解码失败:" + err.Error()
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "row" {
|
||||
continue
|
||||
}
|
||||
var re xlsxRowEl
|
||||
if err := dec.DecodeElement(&re, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
cells := map[int]string{}
|
||||
maxCol := -1
|
||||
for _, c := range re.Cells {
|
||||
if count >= xlsxMaxCells {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
count++
|
||||
col := xlsxColIndex(c.Ref)
|
||||
if col < 0 {
|
||||
col = maxCol + 1 // 无引用:按出现顺序接续
|
||||
}
|
||||
if col >= xlsxMaxCols {
|
||||
// 稀疏样式单元格常落在远端列(XFD 区):跳过该格并告警,
|
||||
// 不中断本表其余内容,也不阻断后续工作表。
|
||||
wide = true
|
||||
continue
|
||||
}
|
||||
cells[col] = xlsxCellText(c, shared, dateStyles, date1904)
|
||||
if col > maxCol {
|
||||
maxCol = col
|
||||
}
|
||||
}
|
||||
if len(cells) == 0 {
|
||||
continue
|
||||
}
|
||||
full := make([]string, maxCol+1)
|
||||
for i, v := range cells {
|
||||
full[i] = v
|
||||
}
|
||||
for len(full) > 0 && strings.TrimSpace(full[len(full)-1]) == "" {
|
||||
full = full[:len(full)-1]
|
||||
}
|
||||
if len(full) > 0 {
|
||||
rows = append(rows, full)
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, count, truncated, wide, ""
|
||||
}
|
||||
return &core.DocBlock{Type: core.BlockTable, Rows: rows}, count, truncated, wide, ""
|
||||
}
|
||||
|
||||
// xlsxCellText 把单元格转成展示文本。
|
||||
func xlsxCellText(c xlsxCellEl, shared []string, dateStyles map[int]bool, date1904 bool) string {
|
||||
text := ""
|
||||
switch c.T {
|
||||
case "s":
|
||||
if idx, err := strconv.Atoi(c.V); err == nil && idx >= 0 && idx < len(shared) {
|
||||
text = shared[idx]
|
||||
}
|
||||
case "inlineStr":
|
||||
if c.IS != nil {
|
||||
text = c.IS.T
|
||||
}
|
||||
case "b":
|
||||
if c.V == "1" || strings.EqualFold(c.V, "true") {
|
||||
text = "TRUE"
|
||||
} else {
|
||||
text = "FALSE"
|
||||
}
|
||||
case "e":
|
||||
text = c.V
|
||||
default: // n 或空:数字 / 公式缓存值
|
||||
text = c.V
|
||||
if sIdx, err := strconv.Atoi(c.S); err == nil && dateStyles[sIdx] && c.V != "" {
|
||||
if f, err := strconv.ParseFloat(c.V, 64); err == nil {
|
||||
text = xlsxSerialString(f, date1904)
|
||||
}
|
||||
}
|
||||
}
|
||||
if text == "" && c.F != "" {
|
||||
return "=" + c.F // 无缓存值的公式:回显公式本身
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// xlsxSerialString Excel 日期序列值 → ISO 字符串;带小数部分时输出到秒。
|
||||
func xlsxSerialString(serial float64, date1904 bool) string {
|
||||
days := math.Floor(serial)
|
||||
frac := serial - days
|
||||
var t time.Time
|
||||
switch {
|
||||
case date1904:
|
||||
t = time.Date(1904, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
|
||||
case days >= 61:
|
||||
t = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
|
||||
default: // 1–60:兼容 Excel 的 1900 闰年 bug 区段
|
||||
t = time.Date(1899, 12, 31, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
|
||||
}
|
||||
if frac > 0 {
|
||||
return t.Add(time.Duration(frac * 86400 * float64(time.Second))).Round(time.Second).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// xlsxColIndex "BC12" → 0 起列号;无引用或非法返回 -1。
|
||||
func xlsxColIndex(ref string) int {
|
||||
if ref == "" {
|
||||
return -1
|
||||
}
|
||||
i := 0
|
||||
for i < len(ref) {
|
||||
ch := ref[i]
|
||||
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if i == 0 || i == len(ref) {
|
||||
return -1
|
||||
}
|
||||
for _, ch := range ref[i:] {
|
||||
if ch < '0' || ch > '9' {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for _, ch := range ref[:i] {
|
||||
c := ch
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c = c - 'a' + 'A'
|
||||
}
|
||||
n = n*26 + int(c-'A') + 1
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
@@ -24,20 +24,74 @@ type MatchResult struct {
|
||||
ID string
|
||||
Slots map[string]string
|
||||
Missing []SlotQuestion
|
||||
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
|
||||
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
|
||||
// Router/runmgr 等消费方只依赖本包表面。
|
||||
type (
|
||||
Command = agentintent.Command
|
||||
ActionRef = agentintent.ActionRef
|
||||
)
|
||||
|
||||
const (
|
||||
CommandStart = agentintent.CommandStart
|
||||
ActionAuto = agentintent.ActionAuto
|
||||
ActionCommand = agentintent.ActionCommand
|
||||
ActionWorkflow = agentintent.ActionWorkflow
|
||||
ActionBlueprint = agentintent.ActionBlueprint
|
||||
ActionHandler = agentintent.ActionHandler
|
||||
)
|
||||
|
||||
type Gate struct {
|
||||
mu sync.RWMutex
|
||||
reg *agentintent.Registry
|
||||
rec *agentintent.FunnelRecognizer
|
||||
specs int
|
||||
specsByID map[string]agentintent.IntentSpec
|
||||
// 慢通道(I2):nil = 不启用(默认,行为与 I1 一致)。
|
||||
sem agentintent.SemanticScorer
|
||||
semThresh float64
|
||||
gen agentintent.CommandGenerator
|
||||
}
|
||||
|
||||
func New() *Gate {
|
||||
return &Gate{rec: agentintent.NewRecognizer(agentintent.Options{}, nil, nil)}
|
||||
}
|
||||
|
||||
// SetSemantic 接线语义打分器(慢通道中位)。识别器随之重建;
|
||||
// 传 nil 关闭语义层。阈值 ≤0 用库默认(0.8)。
|
||||
func (g *Gate) SetSemantic(s agentintent.SemanticScorer, threshold float64) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.sem, g.semThresh = s, threshold
|
||||
g.rebuildRecognizerLocked()
|
||||
}
|
||||
|
||||
// SetGenerator 接线模型命令生成器(慢通道末位)。识别器随之重建;
|
||||
// 传 nil 关闭生成层。
|
||||
func (g *Gate) SetGenerator(gen agentintent.CommandGenerator) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.gen = gen
|
||||
g.rebuildRecognizerLocked()
|
||||
}
|
||||
|
||||
// rebuildRecognizerLocked 按当前慢通道配置重建识别器(调用方持写锁)。
|
||||
func (g *Gate) rebuildRecognizerLocked() {
|
||||
if g.sem == nil && g.gen == nil {
|
||||
g.rec = agentintent.NewRecognizer(agentintent.Options{}, nil, nil)
|
||||
return
|
||||
}
|
||||
g.rec = agentintent.NewRecognizer(agentintent.Options{
|
||||
Semantic: g.sem,
|
||||
SemanticThreshold: g.semThresh,
|
||||
Generator: g.gen,
|
||||
}, nil, nil)
|
||||
}
|
||||
|
||||
// Rebuild 用插件意图 overlay 重建注册表;失败保留上一快照(fail-closed)并返回错误。
|
||||
func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
|
||||
if len(overlay) == 0 {
|
||||
@@ -54,7 +108,7 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
|
||||
}
|
||||
specs = append(specs, s)
|
||||
}
|
||||
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3})
|
||||
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3, NFKC: true})
|
||||
if err := b.Register(specs...); err != nil {
|
||||
return fmt.Errorf("intentgate: register: %w", err)
|
||||
}
|
||||
@@ -95,6 +149,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
switch res.Branch {
|
||||
case agentintent.BranchMatched:
|
||||
out.Branch = "matched"
|
||||
out.Commands = res.Commands
|
||||
case agentintent.BranchAmbiguous:
|
||||
out.Branch = "ambiguous"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
)
|
||||
|
||||
func raw(t *testing.T, v any) json.RawMessage {
|
||||
@@ -91,3 +93,86 @@ func TestRecognizeKeepsSlots(t *testing.T) {
|
||||
t.Fatalf("Match wrapper = %s %s", branch, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- I2 慢通道接线(默认关;SetSemantic/SetGenerator 开启)----
|
||||
|
||||
// stubScorer 固定返回脚本分(按 examples[0] 查表)。
|
||||
type stubScorer struct{ scores map[string]float64 }
|
||||
|
||||
func (s stubScorer) Score(_ context.Context, _ string, examples []string) (float64, error) {
|
||||
if len(examples) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.scores[examples[0]], nil
|
||||
}
|
||||
|
||||
// stubGenerator 固定返回脚本命令。
|
||||
type stubGenerator struct{ cmds []Command }
|
||||
|
||||
func (g stubGenerator) Generate(_ context.Context, _ string, _ []agentintent.CatalogSpec) ([]agentintent.Command, error) {
|
||||
return g.cmds, nil
|
||||
}
|
||||
|
||||
func ambiguousOverlay(t *testing.T) []json.RawMessage {
|
||||
t.Helper()
|
||||
mk := func(id string, ex []string) json.RawMessage {
|
||||
return raw(t, map[string]any{
|
||||
"id": id, "domain": "demo", "version": 1, "priority": 5,
|
||||
"matcher": map[string]any{"includes_any": []string{"模糊词"}},
|
||||
"examples": ex,
|
||||
})
|
||||
}
|
||||
return []json.RawMessage{mk("demo.alpha", []string{"查运行进度"}), mk("demo.beta", []string{"闲聊问候"})}
|
||||
}
|
||||
|
||||
// 语义层:歧义被过阈打分裁决为 matched(默认关时不启用)。
|
||||
func TestGateSemanticResolvesAmbiguous(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), ambiguousOverlay(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 默认(未接线):规则层歧义原样透出。
|
||||
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
|
||||
t.Fatalf("默认关:branch = %s", branch)
|
||||
}
|
||||
g.SetSemantic(stubScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}, 0)
|
||||
branch, top := g.Match(context.Background(), "模糊词")
|
||||
if branch != "matched" || top != "demo.alpha" {
|
||||
t.Fatalf("语义裁决:%s/%s", branch, top)
|
||||
}
|
||||
// 关闭后回到歧义。
|
||||
g.SetSemantic(nil, 0)
|
||||
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
|
||||
t.Fatalf("关闭语义层:branch = %s", branch)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成层:fallback 时产出合法 start 命令 → matched + Commands。
|
||||
func TestGateGeneratorProducesCommands(t *testing.T) {
|
||||
g := New()
|
||||
overlay := []json.RawMessage{raw(t, map[string]any{
|
||||
"id": "report.generate", "domain": "report", "version": 1,
|
||||
"description": "生成报告",
|
||||
"matcher": map[string]any{"includes_any": []string{"生成报告"}},
|
||||
"handler": "reportHandler",
|
||||
})}
|
||||
if err := g.Rebuild(context.Background(), overlay); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
|
||||
}}})
|
||||
res := g.Recognize(context.Background(), "完全不相关的一句话")
|
||||
if res.Branch != "matched" || res.ID != "report.generate" {
|
||||
t.Fatalf("生成器命中:%+v", res)
|
||||
}
|
||||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
|
||||
t.Fatalf("commands: %+v", res.Commands)
|
||||
}
|
||||
// 关闭后回到 fallback。
|
||||
g.SetGenerator(nil)
|
||||
if branch, _ := g.Match(context.Background(), "完全不相关的一句话"); branch != "fallback" {
|
||||
t.Fatalf("关闭生成层:branch = %s", branch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package intentsem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// LightGenerator 用轻量模型实现 agentintent.CommandGenerator
|
||||
// (Rasa 式 LLM command generator:只把相关流程交给生成器)。
|
||||
// 提示词给出能力目录(spec id/描述/槽位键),要求模型输出严格的
|
||||
// 命令 JSON 数组;解析失败或模型不可达都返回错误——库侧约定生成器
|
||||
// 出错时识别保持 fallback(fail-closed)。
|
||||
type LightGenerator struct {
|
||||
// Stream 提供方边界(注入点;测试可脚本化)。
|
||||
Stream agentllm.StreamFunc
|
||||
// Model 是轻量档模型名。
|
||||
Model string
|
||||
// APIBase/APIKey 走 StreamOptions(客户端可按次覆盖)。
|
||||
APIBase string
|
||||
APIKey string
|
||||
// MaxTokens 上限;0 = 1024。
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// 生成输出契约:action 与 agentintent.ActionRef 同形(kind/id)。
|
||||
type generatedCommand struct {
|
||||
Kind string `json:"kind"`
|
||||
Action generatedAction `json:"action,omitempty"`
|
||||
Slots map[string]string `json:"slots,omitempty"`
|
||||
}
|
||||
|
||||
type generatedAction struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Generate 基于输入与能力目录产出命令列表。目录为空时不调用模型。
|
||||
func (g LightGenerator) Generate(ctx context.Context, input string, catalog []agentintent.CatalogSpec) ([]agentintent.Command, error) {
|
||||
if g.Stream == nil || len(catalog) == 0 || strings.TrimSpace(input) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
maxTok := g.MaxTokens
|
||||
if maxTok <= 0 {
|
||||
maxTok = 1024
|
||||
}
|
||||
msgs := []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: generatorSystemPrompt(catalog)},
|
||||
{Role: agentllm.RoleUser, Content: input},
|
||||
}
|
||||
ch := g.Stream(ctx, g.Model, msgs, nil, agentllm.StreamOptions{
|
||||
MaxTokens: maxTok, APIBase: g.APIBase, APIKey: g.APIKey,
|
||||
})
|
||||
var sb strings.Builder
|
||||
for ev := range ch {
|
||||
switch ev.Kind {
|
||||
case agentllm.KindTextDelta:
|
||||
sb.WriteString(ev.Text)
|
||||
case agentllm.KindError:
|
||||
if ev.Err != nil {
|
||||
return nil, fmt.Errorf("intentsem: stream: %w", ev.Err)
|
||||
}
|
||||
return nil, fmt.Errorf("intentsem: stream: %s", ev.ErrorMessage)
|
||||
}
|
||||
}
|
||||
return parseCommands(sb.String())
|
||||
}
|
||||
|
||||
// parseCommands 从模型输出解析命令数组:剥代码围栏,取首个 [ 到末个 ],
|
||||
// 严格 JSON。任何形状不符都报错(由库侧 fail-closed 到 fallback)。
|
||||
func parseCommands(raw string) ([]agentintent.Command, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
text = strings.TrimPrefix(text, "```json")
|
||||
text = strings.TrimPrefix(text, "```")
|
||||
text = strings.TrimSuffix(text, "```")
|
||||
text = strings.TrimSpace(text)
|
||||
lo, hi := strings.Index(text, "["), strings.LastIndex(text, "]")
|
||||
if lo < 0 || hi <= lo {
|
||||
return nil, fmt.Errorf("intentsem: 输出不含命令数组")
|
||||
}
|
||||
var gen []generatedCommand
|
||||
if err := json.Unmarshal([]byte(text[lo:hi+1]), &gen); err != nil {
|
||||
return nil, fmt.Errorf("intentsem: 命令 JSON: %w", err)
|
||||
}
|
||||
out := make([]agentintent.Command, 0, len(gen))
|
||||
for _, c := range gen {
|
||||
out = append(out, agentintent.Command{
|
||||
Kind: agentintent.CommandKind(c.Kind),
|
||||
Action: agentintent.ActionRef{Kind: c.Action.Kind, ID: c.Action.ID},
|
||||
Slots: c.Slots,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// generatorSystemPrompt 渲染能力目录。只暴露 id/描述/槽位键——
|
||||
// 不暴露模式与示例(避免提示词注入借道示例文本进入慢通道)。
|
||||
func generatorSystemPrompt(catalog []agentintent.CatalogSpec) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("你是意图命令生成器。根据用户输入,从下面的能力目录选择并输出命令。\n")
|
||||
sb.WriteString("只输出一个 JSON 数组,不要输出任何其他文字。数组元素形如:\n")
|
||||
sb.WriteString(`[{"kind":"start","action":{"kind":"workflow","id":"<目录中的id>"},"slots":{"<槽位键>":"<值>"}}]` + "\n")
|
||||
sb.WriteString("规则:kind 取 start/set_slot/cancel/clarify/chitchat/handoff 之一;")
|
||||
sb.WriteString("start 必须带 action,action.kind 取 auto/command/workflow/blueprint/handler;")
|
||||
sb.WriteString("action.id 与 slots 的键必须来自目录;用户闲聊用 chitchat(不带 action);")
|
||||
sb.WriteString("无法判断时输出空数组 []。\n能力目录:\n")
|
||||
for _, spec := range catalog {
|
||||
sb.WriteString("- id=" + spec.ID)
|
||||
if spec.Description != "" {
|
||||
sb.WriteString(" 描述=" + spec.Description)
|
||||
}
|
||||
if spec.Handler != "" {
|
||||
sb.WriteString(" handler=" + spec.Handler)
|
||||
}
|
||||
if len(spec.Slots) > 0 {
|
||||
keys := make([]string, 0, len(spec.Slots))
|
||||
for _, sl := range spec.Slots {
|
||||
keys = append(keys, sl.Key)
|
||||
}
|
||||
sb.WriteString(" 槽位=[" + strings.Join(keys, ",") + "]")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package intentsem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// fakeEmbedder 返回脚本化向量:texts[i] → vecs[i](循环取模)。
|
||||
type fakeEmbedder struct {
|
||||
vecs [][]float32
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make([][]float32, len(texts))
|
||||
for i := range texts {
|
||||
out[i] = f.vecs[i%len(f.vecs)]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestEmbeddingScorerMaxCosine(t *testing.T) {
|
||||
// input 与 example2 同向(相似度 1),与 example1 正交(0)。
|
||||
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{
|
||||
{1, 0}, {0, 1}, {2, 0},
|
||||
}}}
|
||||
got, err := s.Score(context.Background(), "输入", []string{"例一", "例二"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 1 {
|
||||
t.Fatalf("期望最大余弦 1,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingScorerErrorPropagates(t *testing.T) {
|
||||
s := EmbeddingScorer{Provider: fakeEmbedder{err: errors.New("boom")}}
|
||||
if _, err := s.Score(context.Background(), "x", []string{"y"}); err == nil {
|
||||
t.Fatal("期望错误上抛(库侧保持 Ambiguous)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingScorerEmptyExamples(t *testing.T) {
|
||||
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{{1}}}}
|
||||
got, err := s.Score(context.Background(), "x", nil)
|
||||
if err != nil || got != 0 {
|
||||
t.Fatalf("空示例应得 0 分无错误,得到 %v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedStream 回放固定文本增量后关闭通道。
|
||||
func scriptedStream(text string, err error) agentllm.StreamFunc {
|
||||
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 3)
|
||||
if err != nil {
|
||||
ch <- agentllm.Event{Kind: agentllm.KindError, Err: err}
|
||||
} else {
|
||||
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
}
|
||||
|
||||
func catalogFixture() []agentintent.CatalogSpec {
|
||||
return []agentintent.CatalogSpec{
|
||||
{ID: "deploy.run", Description: "部署应用", Slots: []agentintent.CatalogSlot{{Key: "env", Required: true}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorParsesCommands(t *testing.T) {
|
||||
g := LightGenerator{
|
||||
Stream: scriptedStream(" ```json\n[{\"kind\":\"start\",\"action\":{\"kind\":\"workflow\",\"id\":\"deploy.run\"},\"slots\":{\"env\":\"prod\"}}]\n``` ", nil),
|
||||
Model: "lite",
|
||||
}
|
||||
cmds, err := g.Generate(context.Background(), "把应用部署到生产", catalogFixture())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandStart {
|
||||
t.Fatalf("期望 1 条 start 命令,得到 %+v", cmds)
|
||||
}
|
||||
if cmds[0].Action.Kind != "workflow" || cmds[0].Action.ID != "deploy.run" {
|
||||
t.Fatalf("action 不符:%+v", cmds[0].Action)
|
||||
}
|
||||
if cmds[0].Slots["env"] != "prod" {
|
||||
t.Fatalf("槽位不符:%+v", cmds[0].Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorEmptyCatalogNoCall(t *testing.T) {
|
||||
called := false
|
||||
g := LightGenerator{Stream: func(_ context.Context, _ string, _ []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
called = true
|
||||
ch := make(chan agentllm.Event)
|
||||
close(ch)
|
||||
return ch
|
||||
}}
|
||||
cmds, err := g.Generate(context.Background(), "你好", nil)
|
||||
if err != nil || cmds != nil || called {
|
||||
t.Fatalf("空目录不应调用模型:cmds=%v err=%v called=%v", cmds, err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorStreamError(t *testing.T) {
|
||||
g := LightGenerator{Stream: scriptedStream("", errors.New("dial refused"))}
|
||||
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil ||
|
||||
!strings.Contains(err.Error(), "dial refused") {
|
||||
t.Fatalf("期望流错误上抛,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorBadJSON(t *testing.T) {
|
||||
g := LightGenerator{Stream: scriptedStream("我无法判断", nil)}
|
||||
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil {
|
||||
t.Fatal("期望解析失败返回错误(库侧 fail-closed 回 fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandsFencedAndChatty(t *testing.T) {
|
||||
raw := "好的,命令如下:\n```json\n[{\"kind\":\"chitchat\"}]\n```\n以上。"
|
||||
cmds, err := parseCommands(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandChitchat {
|
||||
t.Fatalf("期望 chitchat,得到 %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorPromptContainsCatalogOnly(t *testing.T) {
|
||||
p := generatorSystemPrompt(catalogFixture())
|
||||
for _, want := range []string{"deploy.run", "部署应用", "env"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Fatalf("提示词缺少 %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(p, "正例") || strings.Contains(p, "examples") {
|
||||
t.Fatal("提示词不应包含示例文本(注入面)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package intentsem 慢通道实现(I2 增量三):把 agent-intent 的
|
||||
// SemanticScorer / CommandGenerator 端口接到宿主已有的向量与轻量模型
|
||||
// 能力上。两个实现都是可插拔的——装配层默认不接线(设置开关关),
|
||||
// 此时识别行为与 I1 完全一致。
|
||||
package intentsem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EmbeddingScorer 用语义向量实现 agentintent.SemanticScorer:
|
||||
// 对 input 与每个 example 取向量,返回最大余弦相似度。
|
||||
// 任何错误都原样上抛——库侧约定 scorer 出错时识别保持 Ambiguous,
|
||||
// 不会使 Recognize 失败(fail-open 到规则层结论)。
|
||||
type EmbeddingScorer struct {
|
||||
Provider core.EmbeddingProvider
|
||||
}
|
||||
|
||||
// Score 返回 input 与 examples 的最高余弦相似度,取值 [0,1]。
|
||||
func (s EmbeddingScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
|
||||
if s.Provider == nil || len(examples) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
texts := make([]string, 0, len(examples)+1)
|
||||
texts = append(texts, input)
|
||||
texts = append(texts, examples...)
|
||||
vecs, err := s.Provider.Embed(ctx, texts)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(vecs) != len(texts) || len(vecs[0]) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
best := 0.0
|
||||
for _, v := range vecs[1:] {
|
||||
if sim := cosine(vecs[0], v); sim > best {
|
||||
best = sim
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// cosine 计算两个等长向量的余弦相似度;任一零向量或不等长返回 0。
|
||||
func cosine(a, b []float32) float64 {
|
||||
if len(a) == 0 || len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
fa, fb := float64(a[i]), float64(b[i])
|
||||
dot += fa * fb
|
||||
na += fa * fa
|
||||
nb += fb * fb
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// agentBannedKeys 是插件代理声明禁带的键:钩子、MCP 与权限模式归宿主与
|
||||
// 基座,插件画像只能声明身份与工具面(N1 WP1 验收条款)。
|
||||
var agentBannedKeys = map[string]bool{
|
||||
"hooks": true,
|
||||
"mcp": true,
|
||||
"permissionMode": true,
|
||||
}
|
||||
|
||||
// parseAgentAsset 解析 agents/ 资产:禁带键定位到字段、未知键拒绝、
|
||||
// id 必填。任一违规由调用方整批拒绝该插件(fail-closed)。
|
||||
func parseAgentAsset(raw []byte) (core.PluginAgentDecl, error) {
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
|
||||
}
|
||||
for key := range probe {
|
||||
if agentBannedKeys[key] {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("插件代理不得声明 %s", key)
|
||||
}
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var item core.PluginAgentDecl
|
||||
if err := dec.Decode(&item); err != nil {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
|
||||
}
|
||||
if item.ID == "" {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("agent missing id")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// writeAgentPlugin 写一个带 agents/ 资产的插件目录。
|
||||
func writeAgentPlugin(t *testing.T, dir, id, agentJSON string) {
|
||||
t.Helper()
|
||||
root := filepath.Join(dir, id)
|
||||
os.MkdirAll(filepath.Join(root, "agents"), 0o755)
|
||||
write := func(rel, content string) {
|
||||
if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("plugin.json", `{"id":"`+id+`","name":"代理插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`",
|
||||
"provides":{"agents":["reviewer"]}}`)
|
||||
write("agents/reviewer.json", agentJSON)
|
||||
}
|
||||
|
||||
func TestAgentAssetLoads(t *testing.T) {
|
||||
h, _, dir := newHost(t)
|
||||
writeAgentPlugin(t, dir, "agentplug", `{"id":"rdm-reviewer","title":"章节评审","instructions":"逐条核对来源","tools":["read_file","grep"]}`)
|
||||
if err := h.LoadDir()["agentplug"]; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ld := range h.List() {
|
||||
if ld.Manifest.ID != "agentplug" {
|
||||
continue
|
||||
}
|
||||
if len(ld.Agents) != 1 || ld.Agents[0].ID != "rdm-reviewer" || len(ld.Agents[0].Tools) != 2 {
|
||||
t.Fatalf("agents = %+v", ld.Agents)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("plugin not loaded")
|
||||
}
|
||||
|
||||
// TestAgentAssetBansHostOnlyKeys:插件代理声明 hooks/mcp/permissionMode 时
|
||||
// 整批拒绝且错误定位到字段(N1 WP1 验收条款)。
|
||||
func TestAgentAssetBansHostOnlyKeys(t *testing.T) {
|
||||
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
|
||||
h, _, dir := newHost(t)
|
||||
writeAgentPlugin(t, dir, "badplug", `{"id":"x","`+key+`":{}}`)
|
||||
err := h.LoadDir()["badplug"]
|
||||
if err == nil {
|
||||
t.Fatalf("%s accepted", key)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不得声明 "+key) {
|
||||
t.Fatalf("%s error 未定位到字段: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAssetRejectsUnknownKeysAndMissingID(t *testing.T) {
|
||||
h, _, dir := newHost(t)
|
||||
writeAgentPlugin(t, dir, "unknownkey", `{"id":"x","bogus":1}`)
|
||||
if err := h.LoadDir()["unknownkey"]; err == nil {
|
||||
t.Fatal("unknown key accepted")
|
||||
}
|
||||
h2, _, dir2 := newHost(t)
|
||||
writeAgentPlugin(t, dir2, "noid", `{"title":"无 id"}`)
|
||||
if err := h2.LoadDir()["noid"]; err == nil {
|
||||
t.Fatal("missing id accepted")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/skills"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
@@ -59,6 +60,10 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugins: %s: skill %s: %w", m.ID, name, err)
|
||||
}
|
||||
// 技能按开放规范校验(N2 WP4):不合规整批拒载,字段定位到键。
|
||||
if err := skills.ValidateForPlugin(m.ID, name, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if ld.Skills == nil {
|
||||
ld.Skills = map[string]string{}
|
||||
}
|
||||
@@ -98,8 +103,12 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
|
||||
ld.Parsers = append(ld.Parsers, item)
|
||||
}
|
||||
for _, name := range m.Provides.Agents {
|
||||
var item core.PluginAgentDecl
|
||||
if err := readAsset(dir, "agents", name, &item); err != nil {
|
||||
raw, err := readPluginFile(dir, filepath.Join("agents", name+".json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
|
||||
}
|
||||
item, err := parseAgentAsset(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
|
||||
}
|
||||
ld.Agents = append(ld.Agents, item)
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugins
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -33,7 +34,7 @@ func TestV2AssetsAndNetwork(t *testing.T) {
|
||||
"provides":{"tools":["echo"],"skills":["note"],"mcpServers":["local"],"knowledge":["regs"],"parsers":["pdf"],"agents":["writer"],"channels":["feishu"],"pages":["home"]}
|
||||
}`)
|
||||
write("tools/echo.json", `{"name":"v2_echo","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://api.example.com/x"}}`)
|
||||
write("skills/note.md", "# note\n")
|
||||
write("skills/note.md", "---\nname: note\ndescription: 笔记技能\n---\n\n# note\n")
|
||||
write("mcp/local.json", `{"name":"local","command":"echo"}`)
|
||||
write("knowledge/regs.json", `{"id":"regs","title":"法规"}`)
|
||||
write("parsers/pdf.json", `{"id":"pdf","title":"PDF","mime":"application/pdf"}`)
|
||||
@@ -95,3 +96,33 @@ func TestSidecarTouch(t *testing.T) {
|
||||
}
|
||||
t.Fatal("sidecar did not run")
|
||||
}
|
||||
|
||||
// N2 WP4:不合规插件技能整批拒载,错误带插件上下文与字段定位。
|
||||
func TestV2InvalidSkillRejected(t *testing.T) {
|
||||
host, _, dir := newHost(t)
|
||||
root := filepath.Join(dir, "badskill")
|
||||
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(rel, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("plugin.json", `{
|
||||
"id":"badskill","name":"Bad","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
|
||||
"backend":{"schemaVersion":1},
|
||||
"provides":{"skills":["note"]}
|
||||
}`)
|
||||
// 缺 name(开放规范必填)。
|
||||
write("skills/note.md", "---\ndescription: 没有名字\n---\n正文\n")
|
||||
err := host.LoadDir()["badskill"]
|
||||
if err == nil {
|
||||
t.Fatal("不合规技能必须拒载")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "plugins: badskill: skill note:") ||
|
||||
!strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("错误须带插件上下文与字段定位:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
|
||||
|
||||
1. **定样式**,按下面的优先级只取第一个命中项:本轮用户的明确指令(例如改成公文、换成品牌色)→ 本轮上传的模板(.docx/.dotx/.pptx/.potx/.xlsx/.xltx)→ 本轮要求仿制的参考文档(`from housestyle.extract import extract`,抽取字体、字号、色板、页边距,得到自定义主题)→ 本会话已经确定的样式 → 设置中的默认模板 → 设置中的 deliverables.style(modern / formal / minimal)和强调色。未指定时用环境段给出的默认样式。
|
||||
2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。
|
||||
3. **生成**:`import housestyle`,用 docx_kit / pptx_kit / xlsx_kit 的构件写文件。文件落在会话工作区。模板文件和参考原件在 `inputs/`(环境段列出相对路径)。
|
||||
4. **自检**:`python -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `housestyle.summarize` 回读标题、表格和页数,核对与规划一致。
|
||||
3. **生成(Word 交付物优先走源码化)**:先写 Markdown 源文件(`write_file 方案.md`,约定见 `python3 -m housestyle.build` 的 docstring:`#`标题/`##`-`####` 三级、`-`/`1.` 列表、`| |` 表格、`>` 引用、`!!! 标签 文本` 强调块、首行 `<!-- housestyle: theme=modern -->` 可选主题),再 `python3 -m housestyle.build 方案.md` 确定性渲染成 docx——build 自动跑 lint 并回读结构(写入即诊断),有 error 就改 .md 源的对应段落再重建,不要整段重写。修改已有交付物 = `edit_file` 改 .md 源 + 重新 build。Excel/PPT/图片等其他类型仍用 `run_python` + 对应 kit。文件都在会话工作区;模板和参考原件在 `inputs/`(环境段列出相对路径)。
|
||||
4. **自检**:`python3 -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `python3 -m housestyle.outline_cli <文件>` 回读标题、表格和页数,核对与规划一致。
|
||||
5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。
|
||||
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: base.method
|
||||
layer: base
|
||||
version: 1
|
||||
title: 工作方法
|
||||
description: 把任务做完的方法论:计划、换路、查 API、坚持到完成、回到材料(常驻基座)
|
||||
---
|
||||
按以下方法工作:
|
||||
|
||||
- **先计划再动手**:预计三步以上、或要产出文件的任务,开始前用 update_plan 列出步骤;每完成一步就更新状态,同一时间只有一步进行中。计划是给自己看的进度账,不是给用户看的排场。
|
||||
- **一个方法失败两次就换路**:同样的调用连续失败两次,停下来想第三条路(换工具、换参数结构、换实现方式),不要原样重试第三次。
|
||||
- **写脚本前先查 API**:调用不熟悉的库/模块前,先用 grep 查它的函数签名(`inspect.signature`/`dir`)或读 SKILL.md 的示例,确认参数形态后再写完整脚本。猜 API 是失败的最大来源。
|
||||
- **编辑粒度**:修改文件时先想清楚要改哪几处,用 multi_edit 或大块 edit 一次改完,不要一行一个 edit_file 连发十几次;改完立即验证(build/运行),验证通过再继续下一处。
|
||||
- **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。
|
||||
- **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。
|
||||
- **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。
|
||||
@@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果
|
||||
---
|
||||
使用工具时遵循:
|
||||
|
||||
- 优先专用工具而非通用手段:检索知识用检索工具、写产物用工件工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
|
||||
- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
|
||||
- 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。
|
||||
- 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。
|
||||
- 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。
|
||||
|
||||
@@ -12,4 +12,4 @@ description: 动态注入的当前环境事实(占位符随运行填充)
|
||||
- 本轮附带文档:{{attachments}}。文档全文已注入上下文(超长时会标注截断)。
|
||||
- 交付物默认样式:{{deliverableStyle}}。本轮的明确指令、上传的模板或参考文档优先于该默认值。
|
||||
- 技能目录:{{skillsDir}}。环境变量 AGENT_SKILLS_DIR 指向这里;Python 已能 `import housestyle`。
|
||||
- 本轮原件(工作区相对路径):{{inputs}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。
|
||||
- 会话工作区文件(相对路径,跨轮有效):{{inputs}}。原件旁的 <原名>.md 是解析出的纯文本,需要原始信息时优先读它(不要只依赖你上一轮生成的产物,也不要向用户索取已经上传的文件)。模板和参考文档用这些路径。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.edit_file
|
||||
layer: tool
|
||||
version: 1
|
||||
title: edit_file 工具描述
|
||||
description: 工作区文件精确编辑的模型侧说明文案
|
||||
---
|
||||
对工作区文件做唯一匹配替换:old 必须与文件内容逐字一致(含缩进)且在文件中唯一,不唯一时带上更多上下文行。改前必须先 read 过该文件;文件在读取后被外部修改会拒绝,重读即可。多处相关修改用 multi_edit 一次提交,不要一行一个调用连发。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.glob
|
||||
layer: tool
|
||||
version: 1
|
||||
title: glob 工具描述
|
||||
description: 文件名模式匹配的模型侧说明文案
|
||||
---
|
||||
按通配模式找文件路径(** 跨目录),用于"这类文件有哪些/在哪个目录"。找内容用 grep,不要用 glob 后逐个 cat。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.grep
|
||||
layer: tool
|
||||
version: 1
|
||||
title: grep 工具描述
|
||||
description: 工作区内容检索的模型侧说明文案
|
||||
---
|
||||
在工作区文件内容里做正则检索(支持 include 按文件名过滤、ignore_case)。用于定位章节、函数、数据点;配合 read_file 的 offset 直达相关段落,替代整文件通读。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.read_file
|
||||
layer: tool
|
||||
version: 1
|
||||
title: read_file 工具描述
|
||||
description: 工作区文件读取的模型侧说明文案
|
||||
---
|
||||
读取会话工作区内的文本文件,输出带行号(1-based)。用法:先不带 offset/limit 读前 2000 行看全貌;文件大时用 offset/limit 分页,不要反复从头读。编辑前必须先读过目标文件。长输出会被截断并提示分页方式。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.run_python
|
||||
layer: tool
|
||||
version: 1
|
||||
title: run_python 工具描述
|
||||
description: Python 执行原语的模型侧说明文案
|
||||
---
|
||||
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.run_shell
|
||||
layer: tool
|
||||
version: 1
|
||||
title: run_shell 工具描述
|
||||
description: bash 执行原语的模型侧说明文案
|
||||
---
|
||||
在会话工作区内执行 bash 脚本(环境最小化:PATH/HOME/LANG;AGENT_SKILLS_DIR 与 PYTHONPATH 已注入)。适用:housestyle build/lint、pip install --user、文件批操作。读文件用 read_file、找内容用 grep——不要用 cat/sed/grep 命令行代替专用工具。同一命令失败两次换方法。
|
||||
@@ -0,0 +1,33 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var barePythonRe = regexp.MustCompile(`(?:^|[^0-9A-Za-z_.-])python -(?:m|c)\b`)
|
||||
|
||||
// 提示词库里的命令必须能在执行环境跑起来:解释器只有 python3。
|
||||
// 实测模型照提示词执行 `python -m housestyle.lint` 得到 exit 127。
|
||||
func TestLibraryUsesRunnablePythonCommands(t *testing.T) {
|
||||
names, err := fs.Glob(libraryFS, "library/*.md")
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
var problems []string
|
||||
for _, name := range names {
|
||||
raw, err := libraryFS.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, loc := range barePythonRe.FindAllStringIndex(body, -1) {
|
||||
problems = append(problems, name+": bare `python -` command near: "+strings.TrimSpace(body[loc[0]:min(loc[0]+60, len(body))]))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("unrunnable python commands in prompt library:\n%s", strings.Join(problems, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,9 @@ func TestResolveSystemDefaultBase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 默认:base 七块(identity/behavior/caution/style/tools/deliverables/quality)+ env 一块,无技能注入。
|
||||
if len(blocks) != 8 {
|
||||
t.Errorf("blocks = %d, want 8 (7 base + env)", len(blocks))
|
||||
// 默认:base 八块(identity/behavior/method/caution/style/tools/deliverables/quality)+ env 一块,无技能注入。
|
||||
if len(blocks) != 9 {
|
||||
t.Errorf("blocks = %d, want 9 (8 base + env)", len(blocks))
|
||||
}
|
||||
var envBody string
|
||||
for _, b := range blocks {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// P5/裁定 4:拦截路径只认硬事实(lint 实际运行且零 error);
|
||||
// 关键词维度(栏目式标题/可执行性等)不得再把合格交付物拦下来。
|
||||
func TestHardFactsIgnoresKeywordDimensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 评分整体会因「结论先行 20/可读性 25」判负的文本——硬事实口径必须放行
|
||||
//(无 lint 或 lint 通过时)。
|
||||
path := filepath.Join(dir, "报告.md")
|
||||
if err := os.WriteFile(path, []byte("# 概述\n\n正文内容。\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := ScoreFile(path)
|
||||
if full.Passed {
|
||||
t.Fatalf("keyword dimensions should still fail the full score (fixture sanity): %+v", full)
|
||||
}
|
||||
hard := HardFacts("报告.md", []byte("# 概述\n\n正文内容。\n"))
|
||||
if !hard.Passed {
|
||||
t.Fatalf("hard facts must ignore keyword dimensions: %+v", hard.Violations)
|
||||
}
|
||||
if len(hard.Lint) > 0 {
|
||||
for _, issue := range hard.Lint {
|
||||
if issue.Severity == "error" {
|
||||
t.Fatalf("lint errors must still block: %+v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lint 不可运行(技能根已配置)仍是硬事实失败——fail-visible 保持。
|
||||
func TestHardFactsKeepsLintUnavailableBlocking(t *testing.T) {
|
||||
SetLintEnvRoot(t.TempDir())
|
||||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||||
hard := HardFacts("报告.md", []byte("# 概述\n正文。\n"))
|
||||
if hard.Passed {
|
||||
t.Fatalf("lint-not-run must stay blocking: %+v", hard)
|
||||
}
|
||||
found := false
|
||||
for _, v := range hard.Violations {
|
||||
if strings.Contains(v, "自检未运行") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 自检未运行, got %v", hard.Violations)
|
||||
}
|
||||
}
|
||||
+194
-12
@@ -26,12 +26,15 @@ type Dimension struct {
|
||||
}
|
||||
|
||||
// Report 是一次评分。Passed 要求总分不低于 70,且没有任何一维低于 40。
|
||||
// LintRan 为假表示 housestyle.lint 没有实际执行(环境不可用);技能根已注入
|
||||
// (SetLintEnvRoot)时这本身计为一条 Violations,不再把「没跑」静默当「没问题」。
|
||||
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"`
|
||||
LintRan bool `json:"lintRan,omitempty"`
|
||||
}
|
||||
|
||||
// LintIssue 是 housestyle.lint 的一条结果。lint 不存在时留空,不因此判失败。
|
||||
@@ -41,6 +44,17 @@ type LintIssue struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// lintEnvRoot 由组合根在启动时注入(<data>/skills)。零值保持旧语义:
|
||||
// 继承进程环境,lint 不可用时静默跳过(测试与无技能部署)。
|
||||
var lintEnvRoot string
|
||||
|
||||
// SetLintEnvRoot 注入技能根:lint 子进程获得与 run_python 一致的
|
||||
// AGENT_SKILLS_DIR / PYTHONPATH,否则 housestyle 在后端进程里 import 失败。
|
||||
// 非空根同时把「lint 必须可运行」设为强约束:跑不起来即 Violations(fail-visible)。
|
||||
func SetLintEnvRoot(skillsRoot string) {
|
||||
lintEnvRoot = skillsRoot
|
||||
}
|
||||
|
||||
// Judge 用模型给同一份正文打分。返回错误时调用方回落启发式。
|
||||
type Judge func(ctx context.Context, text string) (Report, error)
|
||||
|
||||
@@ -82,20 +96,27 @@ func ScoreText(text string) Report {
|
||||
|
||||
// ScoreFile 读取文件正文再打分。docx 取段落文本;其他文件按 UTF-8 文本读。
|
||||
// 能跑 housestyle.lint 时,error 级问题会使 Passed 变为 false。
|
||||
// lint 无法执行时:技能根已注入(SetLintEnvRoot)→ 记 Violations「自检未运行」
|
||||
// 并判不通过(fail-visible);未注入(测试/无技能部署)→ 保持旧的静默跳过。
|
||||
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)
|
||||
issues, lintErr := lintFile(path)
|
||||
report.Lint = issues
|
||||
report.LintRan = lintErr == nil
|
||||
for _, issue := range issues {
|
||||
if issue.Severity == "error" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
|
||||
}
|
||||
}
|
||||
if lintErr != nil && lintEnvRoot != "" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用:"+lintErr.Error()+")")
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
@@ -176,11 +197,22 @@ func scoreActionable(text string) Dimension {
|
||||
return Dimension{ID: "可执行性", Score: 35, Note: "没有可交给人的行动", Evidence: snippet(text, 40)}
|
||||
}
|
||||
|
||||
// scoreReadable 只检查真正的标题行(# 开头;docx 路径已把标题样式标成 #)。
|
||||
// 此前对每一行做「栏目名词 + 短行」判断,表格单元格(如「项目概述撰写」)
|
||||
// 会被当成标题误报,终轮检查因此对合格文档连续判负(实测死循环源之一)。
|
||||
// 命中时 Note 带行号,模型能定位到要改的那一行。
|
||||
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}
|
||||
for i, line := range strings.Split(text, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
if bannedTitle(title) && utf8.RuneCountInString(title) <= 12 {
|
||||
return Dimension{ID: "可读性", Score: 25, Note: fmt.Sprintf("第 %d 行标题是栏目名,不是判断,改成一句结论", i+1), Evidence: title}
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "。。") {
|
||||
@@ -251,6 +283,9 @@ func readDeliverable(path string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// readDocxText 抽取 docx 纯文本,并把标题样式(Title / HeadingN)的段落标成
|
||||
// Markdown # 前缀——评分卡据此区分「标题行」与「正文/表格单元格」,也让
|
||||
// scoreLead 等维度拿到真实的标题信息。
|
||||
func readDocxText(path string) (string, error) {
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
@@ -274,6 +309,22 @@ func readDocxText(path string) (string, error) {
|
||||
defer rc.Close()
|
||||
decoder := xml.NewDecoder(rc)
|
||||
var b strings.Builder
|
||||
var para strings.Builder
|
||||
style := ""
|
||||
inPara := false
|
||||
flush := func() {
|
||||
text := strings.TrimSpace(para.String())
|
||||
if text != "" {
|
||||
if lvl := headingLevel(style); lvl > 0 {
|
||||
b.WriteString(strings.Repeat("#", min(lvl, 6)))
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(text)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
para.Reset()
|
||||
style = ""
|
||||
}
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
@@ -281,23 +332,70 @@ func readDocxText(path string) (string, error) {
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if el.Name.Local == "p" && b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
switch el.Name.Local {
|
||||
case "p":
|
||||
inPara = true
|
||||
para.Reset()
|
||||
style = ""
|
||||
case "pStyle":
|
||||
if inPara {
|
||||
for _, a := range el.Attr {
|
||||
if a.Name.Local == "val" {
|
||||
style = a.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if el.Name.Local == "p" {
|
||||
inPara = false
|
||||
flush()
|
||||
}
|
||||
case xml.CharData:
|
||||
b.Write(bytes.TrimSpace(el))
|
||||
if inPara {
|
||||
para.Write(bytes.TrimSpace(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
flush()
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// headingLevel 把 docx styleId 映射为标题层级:Heading1..9 → 1..9,
|
||||
// Title → 1,Subtitle → 2;其余(含表格单元格正文)返回 0。
|
||||
func headingLevel(style string) int {
|
||||
switch {
|
||||
case style == "Title":
|
||||
return 1
|
||||
case style == "Subtitle":
|
||||
return 2
|
||||
case strings.HasPrefix(style, "Heading"):
|
||||
n := 0
|
||||
for _, c := range style[len("Heading"):] {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
if n >= 1 && n <= 9 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var lintJSON = regexp.MustCompile(`(?s)\[.*\]`)
|
||||
|
||||
func lintFile(path string) []LintIssue {
|
||||
// lintFile 运行 housestyle.lint。返回 (issues, nil) 表示 lint 实际执行;
|
||||
// 非 nil err 表示没有跑起来(解释器缺失、模块不可导入等),调用方决定是否
|
||||
// 按「自检未运行」暴露。env 与 run_python 的 execEnv 同源语义:继承最小
|
||||
// 环境并附加 AGENT_SKILLS_DIR / PYTHONPATH,否则后端进程里 import 不了 housestyle。
|
||||
func lintFile(path string) ([]LintIssue, error) {
|
||||
cmd := exec.Command("python3", "-m", "housestyle.lint", path)
|
||||
cmd.Env = lintCommandEnv()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && !bytes.Contains(out, []byte("\"rule\"")) {
|
||||
return nil
|
||||
return nil, fmt.Errorf("%s", firstLine(string(out)))
|
||||
}
|
||||
raw := out
|
||||
if loc := lintJSON.Find(out); loc != nil {
|
||||
@@ -305,7 +403,91 @@ func lintFile(path string) []LintIssue {
|
||||
}
|
||||
var issues []LintIssue
|
||||
if json.Unmarshal(raw, &issues) != nil {
|
||||
return nil, fmt.Errorf("lint 输出无法解析:%s", firstLine(string(out)))
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// lintCommandEnv 继承最小环境(PATH/HOME/LANG 等,与 execEnv 的继承面一致)。
|
||||
// 技能根注入后附加 AGENT_SKILLS_DIR 与 PYTHONPATH=<root>/house-style;进程已有
|
||||
// PYTHONPATH 时前置合并(测试与二次包装场景不丢自己的路径)。
|
||||
// lintEnvRoot 为空时返回 nil(不设 cmd.Env,完整继承,保持旧行为)。
|
||||
func lintCommandEnv() []string {
|
||||
if lintEnvRoot == "" {
|
||||
return nil
|
||||
}
|
||||
return issues
|
||||
var out []string
|
||||
var existingPath string
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||
existingPath = strings.TrimPrefix(kv, "PYTHONPATH=")
|
||||
continue
|
||||
}
|
||||
for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
|
||||
if strings.HasPrefix(kv, keep) {
|
||||
out = append(out, kv)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
pp := filepath.Join(lintEnvRoot, "house-style")
|
||||
if existingPath != "" {
|
||||
pp = pp + string(os.PathListSeparator) + existingPath
|
||||
}
|
||||
out = append(out,
|
||||
"AGENT_SKILLS_DIR="+lintEnvRoot,
|
||||
"PYTHONPATH="+pp,
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if utf8.RuneCountInString(s) > 160 {
|
||||
s = string([]rune(s)[:160]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// HardFacts 只评可机械判定的硬事实:lint 实际运行且零 error。
|
||||
// 关键词维度(ScoreText 六维)从拦截路径移出(裁定 4/P5):它们描述的是
|
||||
// 写作质量偏好,实测会把正常中文章节标题判成栏目名、把没有"未指明"字样的
|
||||
// 行动表判成不可执行,造成终轮死循环;评分整体仍由 ScoreBytes 供评测侧使用。
|
||||
func HardFacts(name string, content []byte) Report {
|
||||
report := ScoreBytes(name, content)
|
||||
report.Passed = true
|
||||
report.Violations = nil
|
||||
for _, issue := range report.Lint {
|
||||
if issue.Severity == "error" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
|
||||
}
|
||||
}
|
||||
if !report.LintRan && lintEnvRoot != "" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用)")
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// DeliverableText 供事实核查抽取工件文本(docx 与 ScoreBytes 同源口径;
|
||||
// 二进制返回空)。导出给 runtime 包使用。
|
||||
func DeliverableText(name string, content []byte) string {
|
||||
dir, err := os.MkdirTemp("", "factcheck")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
return ""
|
||||
}
|
||||
text, err := readDeliverable(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLintCommandEnvCarriesSkillsRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
SetLintEnvRoot(root)
|
||||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||||
env := lintCommandEnv()
|
||||
var sawSkills, sawPath bool
|
||||
for _, kv := range env {
|
||||
if kv == "AGENT_SKILLS_DIR="+root {
|
||||
sawSkills = true
|
||||
}
|
||||
if kv == "PYTHONPATH="+filepath.Join(root, "house-style") {
|
||||
sawPath = true
|
||||
}
|
||||
}
|
||||
if !sawSkills || !sawPath {
|
||||
t.Fatalf("lint env missing skills root entries: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// 技能根已注入但 housestyle 不可导入时,评分必须显式报「自检未运行」,
|
||||
// 不能把没跑当成没问题(实测:后端进程无 PYTHONPATH,lint 静默失效)。
|
||||
func TestLintUnavailableFailsVisibleWhenSkillsRootSet(t *testing.T) {
|
||||
SetLintEnvRoot(t.TempDir())
|
||||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||||
good := "# 上传文档应先判断相关性再入库\n\n## 先校验,再生成\n- 说话人1:上传后第一步是判断是否与项目相关。\n一句话:没有记忆库,后面的生成和追溯都没有依据。\n\n## 下一步\n- 事项:写出第一版需求。负责人:陈威。期限:周五前;说不清的写未指明。\n"
|
||||
rep := ScoreBytes("note.md", []byte(good))
|
||||
if rep.Passed {
|
||||
t.Fatalf("lint unavailable must fail visibly when skills root is set: %+v", rep)
|
||||
}
|
||||
found := false
|
||||
for _, v := range rep.Violations {
|
||||
if strings.Contains(v, "自检未运行") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 自检未运行 violation, got: %v", rep.Violations)
|
||||
}
|
||||
if rep.LintRan {
|
||||
t.Fatalf("LintRan should be false when lint could not run")
|
||||
}
|
||||
}
|
||||
|
||||
// docx 标题样式要被标成 # 前缀;表格单元格/正文不参与「栏目式标题」判定
|
||||
// (实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
|
||||
func TestDocxHeadingMarkedAndTableCellNotFlagged(t *testing.T) {
|
||||
docx := buildDocx(t, []paraSpec{
|
||||
{style: "Heading1", text: "概述"},
|
||||
{style: "", text: "项目概述撰写"},
|
||||
{style: "Heading2", text: "先把需求写清楚再开发"},
|
||||
})
|
||||
text, err := readDocxText(docx)
|
||||
if err != nil {
|
||||
t.Fatalf("readDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "# 概述") || !strings.Contains(text, "## 先把需求写清楚再开发") {
|
||||
t.Fatalf("headings not marked with #: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "项目概述撰写") {
|
||||
t.Fatalf("body paragraph missing: %q", text)
|
||||
}
|
||||
|
||||
// 标题是栏目名 → 仍要被判低分。
|
||||
withBadHeading := ScoreText(text)
|
||||
if withBadHeading.Passed {
|
||||
t.Fatalf("banned heading should fail: %+v", withBadHeading)
|
||||
}
|
||||
|
||||
// 同一个「概述」字样只出现在非标题行时,不应触发栏目式标题。
|
||||
cellOnly := buildDocx(t, []paraSpec{
|
||||
{style: "Heading1", text: "先把需求写清楚再开发"},
|
||||
{style: "", text: "项目概述撰写"},
|
||||
{style: "", text: "一句话:先写需求。负责人:陈威。期限:周五前。"},
|
||||
})
|
||||
cellText, err := readDocxText(cellOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("readDocxText: %v", err)
|
||||
}
|
||||
for _, d := range ScoreText(cellText).Dimensions {
|
||||
if d.ID == "可读性" && d.Score < 60 {
|
||||
t.Fatalf("cell text must not be flagged as column heading: %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type paraSpec struct {
|
||||
style string
|
||||
text string
|
||||
}
|
||||
|
||||
// buildDocx 构造最小 docx:只含 word/document.xml(readDocxText 的唯一输入)。
|
||||
func buildDocx(t *testing.T, paras []paraSpec) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
|
||||
for _, p := range paras {
|
||||
b.WriteString(`<w:p>`)
|
||||
if p.style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="` + p.style + `"/></w:pPr>`)
|
||||
}
|
||||
b.WriteString(`<w:r><w:t>` + p.text + `</w:t></w:r></w:p>`)
|
||||
}
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
path := filepath.Join(t.TempDir(), "test.docx")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create docx: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := zip.NewWriter(f)
|
||||
entry, err := w.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("zip entry: %v", err)
|
||||
}
|
||||
if _, err := entry.Write([]byte(b.String())); err != nil {
|
||||
t.Fatalf("zip write: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -124,6 +124,38 @@ func firstRef(docs []core.KnowledgeDoc) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// RetrieveShared EXT-8 余项:本人两路如常 + 追加共享两路(向量 Shared + 全文
|
||||
// Shared,allow=适配器判定),RRF 融合后裁剪 topK。embedder 不可用时共享仅全文
|
||||
// (与切片 2 行为一致)。
|
||||
func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||||
own, err := p.Retrieve(ctx, owner, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := p.st.SearchKnowledgeShared(string(owner), q, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 向量共享路:embedder 可用时并入。
|
||||
if p.embedder != nil && p.embeddingRoute(ctx).OK() {
|
||||
if qvecs, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(qvecs) > 0 && len(qvecs[0]) > 0 {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
vs, e2 := p.st.SearchKnowledgeVectorsShared(ctx, string(owner), qvecs[0], topK, q, allow)
|
||||
if e2 == nil {
|
||||
shared = rrfMerge(vs, shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
merged := rrfMerge(own, shared)
|
||||
if limit := q.TopK; limit > 0 && len(merged) > limit {
|
||||
merged = merged[:limit]
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// Retrieve 统一检索(core.Retriever)。
|
||||
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EXT-8 余项红/绿:向量通道共享——他人 owner 的向量命中经 allow 放行可检。
|
||||
// 用 hash 档 embedder(确定性向量),owner=alice 建项目知识并嵌入;bob 经
|
||||
// RetrieveShared 放行后命中(含 vector Kind),拒绝时零命中。
|
||||
func TestRetrieveSharedVectorChannel(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
set := settings.NewService(st)
|
||||
p := NewPipeline(st, set, nil, nil)
|
||||
p.SetEmbedder(SwitchingEmbedder{Provider: func() string { return "hash" }})
|
||||
|
||||
docs := []core.KnowledgeDoc{{Owner: "alice", ProjectID: "prj-s", Source: "plugin:rdm",
|
||||
Ref: "kb-v", Chunk: "向量共享独占词"}}
|
||||
// 只走管线写入(分块行+向量一体;不再手工 AddKnowledge 避免主键冲突)。
|
||||
if err := indexForTest(t, st, p, docs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allow := func(docOwner, projectID core.ID) bool {
|
||||
return docOwner == "alice" && projectID == "prj-s"
|
||||
}
|
||||
hits, err := p.RetrieveShared(context.Background(), "bob", core.RetrievalQuery{Text: "向量共享独占词", TopK: 5}, allow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("authorized shared vector hit must be retrievable(向量通道共享)")
|
||||
}
|
||||
deny := func(docOwner, projectID core.ID) bool { return false }
|
||||
if hits2, _ := p.RetrieveShared(context.Background(), "bob", core.RetrievalQuery{Text: "向量共享独占词", TopK: 5}, deny); len(hits2) != 0 {
|
||||
t.Fatalf("denied shared search = %d hits, want 0", len(hits2))
|
||||
}
|
||||
}
|
||||
|
||||
// indexForTest:把 docs 直接嵌入并落向量(复用管线的公开写入路径)。
|
||||
func indexForTest(t *testing.T, st *store.Store, p *Pipeline, docs []core.KnowledgeDoc) error {
|
||||
t.Helper()
|
||||
return p.IndexChunks(context.Background(), "alice", docs)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package route 是 Backend 的唯一意图 Router(I1 WP3)。
|
||||
// 它把识别器产出的命令列表统一裁决为路由决策,收编原散落在
|
||||
// server(chat 内联分支)与 runmgr(HITL 自由输入转轨)的两套逻辑。
|
||||
//
|
||||
// 优先级明文(提案 §6 要求 I1 写明):
|
||||
// - auto/handler:命令总线 > 工作流 > 普通回合(与现网行为一致)。
|
||||
// - 显式 command/workflow/blueprint:按声明执行;声明目标在当前环境
|
||||
// 不可解析(命令不存在、工作流未注册、小队未启用)时不静默错配——
|
||||
// 回落普通回合并把原因写进 Reason。
|
||||
// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜;
|
||||
// 意图产出 Start/Clarify 类命令时路由优先。
|
||||
//
|
||||
// 当前版本的已知边界(如实记录,不静默):
|
||||
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
|
||||
// 本版本调用方按普通回合处理并保留 Decision 供审计。
|
||||
// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机
|
||||
// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为
|
||||
// fallback,Start 只来自 matched)。
|
||||
package route
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// Kind 是路由决策类型。
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindCommand Kind = "command"
|
||||
KindWorkflow Kind = "workflow"
|
||||
KindSquad Kind = "squad"
|
||||
KindTurn Kind = "turn"
|
||||
)
|
||||
|
||||
// Decision 是一次路由裁决。
|
||||
type Decision struct {
|
||||
Kind Kind
|
||||
CommandID string
|
||||
Workflow core.WorkflowDef
|
||||
Blueprint string
|
||||
Slots map[string]string
|
||||
Missing []intentgate.SlotQuestion
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析时)
|
||||
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
|
||||
}
|
||||
|
||||
// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。
|
||||
type Env struct {
|
||||
HasCommand func(id string) bool
|
||||
WorkflowFor func(intentID string) (core.WorkflowDef, bool)
|
||||
SquadReady bool
|
||||
}
|
||||
|
||||
// Decide 裁决一次识别结果。规则:
|
||||
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
|
||||
// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。
|
||||
// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流;
|
||||
// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。
|
||||
func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing}
|
||||
if res.Branch != "matched" {
|
||||
return d
|
||||
}
|
||||
var start *intentgate.Command
|
||||
for i := range res.Commands {
|
||||
if res.Commands[i].Kind == intentgate.CommandStart {
|
||||
start = &res.Commands[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
action := intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: res.ID}
|
||||
if start != nil {
|
||||
d.StartFound = true
|
||||
action = start.Action
|
||||
if len(start.Slots) > 0 {
|
||||
d.Slots = start.Slots
|
||||
}
|
||||
}
|
||||
switch action.Kind {
|
||||
case intentgate.ActionCommand:
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
}
|
||||
d.Reason = "声明的命令不存在:" + action.ID
|
||||
case intentgate.ActionWorkflow:
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
}
|
||||
}
|
||||
d.Reason = "声明的工作流未注册:" + action.ID
|
||||
case intentgate.ActionBlueprint:
|
||||
if env.SquadReady {
|
||||
d.Kind, d.Blueprint = KindSquad, action.ID
|
||||
return d
|
||||
}
|
||||
d.Reason = "声明的小队未启用:" + action.ID
|
||||
default: // auto / handler
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
}
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func startCmd(action intentgate.ActionRef) intentgate.Command {
|
||||
return intentgate.Command{Kind: intentgate.CommandStart, Action: action}
|
||||
}
|
||||
|
||||
func TestDecideAutoPrefersCommandThenWorkflow(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"})}}
|
||||
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
|
||||
if d.Kind != KindCommand || d.CommandID != "demo.x" {
|
||||
t.Fatalf("auto command = %+v", d)
|
||||
}
|
||||
d = Decide(in, Env{
|
||||
HasCommand: func(string) bool { return false },
|
||||
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
|
||||
})
|
||||
if d.Kind != KindWorkflow || d.Workflow.ID != "wf" {
|
||||
t.Fatalf("auto workflow = %+v", d)
|
||||
}
|
||||
d = Decide(in, Env{})
|
||||
if d.Kind != KindTurn || !d.StartFound {
|
||||
t.Fatalf("auto turn = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideExplicitKinds(t *testing.T) {
|
||||
mk := func(kind, id string) intentgate.MatchResult {
|
||||
return intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: kind, ID: id})}}
|
||||
}
|
||||
if d := Decide(mk(intentgate.ActionCommand, "cmd-1"), Env{HasCommand: func(id string) bool { return id == "cmd-1" }}); d.Kind != KindCommand || d.CommandID != "cmd-1" {
|
||||
t.Fatalf("explicit command = %+v", d)
|
||||
}
|
||||
d := Decide(mk(intentgate.ActionCommand, "nope"), Env{
|
||||
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
|
||||
})
|
||||
if d.Kind != KindTurn || d.Reason == "" {
|
||||
t.Fatalf("unresolvable command = %+v", d)
|
||||
}
|
||||
if d := Decide(mk(intentgate.ActionWorkflow, "wf-1"), Env{
|
||||
WorkflowFor: func(id string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: id}, true },
|
||||
}); d.Kind != KindWorkflow || d.Workflow.ID != "wf-1" {
|
||||
t.Fatalf("explicit workflow = %+v", d)
|
||||
}
|
||||
if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{SquadReady: true}); d.Kind != KindSquad || d.Blueprint != "bp-1" {
|
||||
t.Fatalf("blueprint = %+v", d)
|
||||
}
|
||||
if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{}); d.Kind != KindTurn || d.Reason == "" {
|
||||
t.Fatalf("blueprint without squad = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideNonMatchedAndLegacyFallback(t *testing.T) {
|
||||
if d := Decide(intentgate.MatchResult{Branch: "fallback"}, Env{}); d.Kind != KindTurn {
|
||||
t.Fatalf("fallback = %+v", d)
|
||||
}
|
||||
// matched 但命令列表为空(旧消费方):按 TopID 走 auto,与现网一致。
|
||||
d := Decide(intentgate.MatchResult{Branch: "matched", ID: "demo.x"}, Env{HasCommand: func(string) bool { return true }})
|
||||
if d.Kind != KindCommand || d.StartFound {
|
||||
t.Fatalf("legacy auto = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotsFromStartCommand(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Commands: []intentgate.Command{{
|
||||
Kind: intentgate.CommandStart,
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"},
|
||||
Slots: map[string]string{"topic": "电机"},
|
||||
}}}
|
||||
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
|
||||
if d.Slots["topic"] != "电机" {
|
||||
t.Fatalf("slots = %v", d.Slots)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package runmgr
|
||||
|
||||
// EXT-14 红测(提案 2026-10-09-ext-g1-b1-b3 §2):带 exposeAsTool 的命令投影为
|
||||
// 回合内工具;模型调用经命令总分发(Entrance="agent"、同审计信封);不带字段的
|
||||
// 命令不出现;高危命令在对话内弹内联确认卡(HITL 收口不变)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// fakeBusSource EXT-14 测试桩:命令清单 + 分发记录(说话用 runmgr 视图类型,
|
||||
// 与生产代码同口径——runmgr 不依赖 cmdbus)。
|
||||
type fakeBusSource struct {
|
||||
cmds []CommandTool
|
||||
lastReq CommandRequest
|
||||
resp CommandResponse
|
||||
}
|
||||
|
||||
func (f *fakeBusSource) CommandTools() []CommandTool { return f.cmds }
|
||||
|
||||
func (f *fakeBusSource) DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse {
|
||||
f.lastReq = req
|
||||
return f.resp
|
||||
}
|
||||
|
||||
func TestCommandToolsProjected(t *testing.T) {
|
||||
rc0, _ := newExecEnv(t)
|
||||
m := rc0.mgr
|
||||
src := &fakeBusSource{cmds: []CommandTool{
|
||||
{ID: "rdm.trace", Description: "查追溯网", Params: []byte(`{"type":"object"}`), Risk: "low"},
|
||||
}}
|
||||
m.SetCommandSource(src)
|
||||
rc := rc0
|
||||
tools := rc.Tools()
|
||||
found := map[string]bool{}
|
||||
for _, d := range tools {
|
||||
found[d.Name] = true
|
||||
}
|
||||
if !found["rdm.trace"] {
|
||||
t.Fatalf("exposeAsTool 命令未投影为工具: %+v", tools)
|
||||
}
|
||||
if found["rdm.hidden"] {
|
||||
t.Fatal("命令源之外的工具不得出现")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandToolDispatchesThroughBus(t *testing.T) {
|
||||
rc0, _ := newExecEnv(t)
|
||||
m := rc0.mgr
|
||||
src := &fakeBusSource{
|
||||
cmds: []CommandTool{{ID: "rdm.trace", Params: []byte(`{"type":"object"}`), Risk: "low"}},
|
||||
resp: CommandResponse{State: "committed"},
|
||||
}
|
||||
m.SetCommandSource(src)
|
||||
rc := rc0
|
||||
|
||||
res := rc.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "rdm.trace", Arguments: []byte(`{"q":"x"}`)})
|
||||
if res.Status != "ok" {
|
||||
t.Fatalf("dispatch result = %s %s", res.Status, res.Output)
|
||||
}
|
||||
req := src.lastReq
|
||||
if req.ID != "rdm.trace" || req.Entrance != "agent" || req.Actor != rc.run.Owner || req.Owner != rc.run.Owner || req.ConversationID != rc.run.ConversationID {
|
||||
t.Fatalf("bus request = %+v(须 Entrance=agent、Actor/Owner=会话 owner、带会话)", req)
|
||||
}
|
||||
if req.Params["q"] != "x" {
|
||||
t.Fatalf("params = %+v", req.Params)
|
||||
}
|
||||
// 未注册命令源时:调用按注册表口径报错(现状),不 panic。
|
||||
reg2 := tools.NewRegistry()
|
||||
if err := reg2.RegisterBuiltin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2 := New(nil, nil, reg2, nil, nil, nil, nil, nil, nil)
|
||||
rc2 := &runCtx{mgr: m2, run: core.Run{Owner: "bob"}, prof: core.AgentProfile{Tools: core.ToolPolicy{Allow: []string{"*"}}}}
|
||||
if res := rc2.ExecuteTool(core.ToolCallRequest{CallID: "c2", Name: "rdm.trace", Arguments: []byte(`{}`)}); res.Status != "error" {
|
||||
t.Fatalf("no source: %s(须 registry miss 报错)", res.Status)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -54,6 +55,60 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
|
||||
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
|
||||
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
|
||||
// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。
|
||||
func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(root, owner, conversation, "inputs")
|
||||
for _, doc := range attachments {
|
||||
name := strings.TrimSpace(doc.Meta.Filename)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".md", ".txt", ".csv", ".json":
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(doc.PlainText())
|
||||
if utf8.RuneCountInString(text) < 200 {
|
||||
continue
|
||||
}
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
out := filepath.Join(dir, stem+".md")
|
||||
if _, err := os.Stat(out); err == nil {
|
||||
continue // 已有同名解析文本(同一附件重发)不覆盖
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(text), 0o644); err == nil {
|
||||
_ = out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。
|
||||
// 覆盖「本轮上传」视角:跨轮可见所有原件与解析文本。目录不存在时回落 staging 列表。
|
||||
func ListWorkspaceInputs(root, owner, conversation string, staged []string) []string {
|
||||
dir := filepath.Join(root, owner, conversation, "inputs")
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return staged
|
||||
}
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
out = append(out, "inputs/"+e.Name())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return staged
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
|
||||
var templateName string
|
||||
var template []byte
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 附件解析文本要落 inputs/<原名>.md 且跨轮可见:后续轮上下文里原件只剩占位时,
|
||||
// 模型仍能读到原文(实测「根据纪要制定方案」轮 0 次读原件,只能编细节)。
|
||||
func TestPersistInputTextsAndListWorkspaceInputs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if _, err := StageWorkspaceInputs(root, "alice", "conv1", []WorkspaceFile{
|
||||
{Name: "会议.docx", Content: []byte("bin")},
|
||||
}, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := strings.Repeat("会议结论先入库。", 40) // >200 字
|
||||
docs := []core.ParsedDocument{{
|
||||
Meta: core.DocMeta{Filename: "会议.docx"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
|
||||
}}
|
||||
PersistInputTexts(root, "alice", "conv1", docs)
|
||||
|
||||
extracted := filepath.Join(root, "alice", "conv1", "inputs", "会议.md")
|
||||
b, err := os.ReadFile(extracted)
|
||||
if err != nil {
|
||||
t.Fatalf("extracted text not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), "会议结论先入库") {
|
||||
t.Fatalf("extracted text wrong: %q", string(b)[:50])
|
||||
}
|
||||
|
||||
// 短文本(解析失败/过短)与已是文本类的原件不写。
|
||||
short := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "短.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "太短"}}}}
|
||||
PersistInputTexts(root, "alice", "conv1", short)
|
||||
textKind := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "笔记.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}}}
|
||||
PersistInputTexts(root, "alice", "conv1", textKind)
|
||||
if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "短.md")); err == nil {
|
||||
t.Fatal("short parse should not be persisted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "笔记.md.md")); err == nil {
|
||||
t.Fatal("text-kind originals should not get a duplicate extract")
|
||||
}
|
||||
|
||||
// 跨轮列出:包含原件 + 解析文本,而不是只有本轮 staging。
|
||||
got := ListWorkspaceInputs(root, "alice", "conv1", []string{"inputs/会议.docx"})
|
||||
joined := strings.Join(got, ",")
|
||||
if !strings.Contains(joined, "inputs/会议.docx") || !strings.Contains(joined, "inputs/会议.md") {
|
||||
t.Fatalf("listing should include original + extracted: %v", got)
|
||||
}
|
||||
// 目录缺失时回落 staging 列表。
|
||||
fallback := ListWorkspaceInputs(root, "alice", "nope", []string{"inputs/only.txt"})
|
||||
if len(fallback) != 1 || fallback[0] != "inputs/only.txt" {
|
||||
t.Fatalf("fallback listing broken: %v", fallback)
|
||||
}
|
||||
// root 未配置:全部 no-op。
|
||||
PersistInputTexts("", "alice", "conv1", docs)
|
||||
if got := ListWorkspaceInputs("", "alice", "conv1", []string{"inputs/a.txt"}); got[0] != "inputs/a.txt" {
|
||||
t.Fatalf("unconfigured root should pass through staged: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/memory"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
|
||||
// owner/项目态由适配器持有(端口本身不传租户态)。
|
||||
type storeMemory struct {
|
||||
st *store.Store
|
||||
owner string
|
||||
projectID string
|
||||
}
|
||||
|
||||
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
|
||||
func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
|
||||
if owner == "" {
|
||||
owner = store.OwnerDefault
|
||||
}
|
||||
return &storeMemory{st: st, owner: owner, projectID: projectID}
|
||||
}
|
||||
|
||||
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
|
||||
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
|
||||
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
|
||||
projectID := ""
|
||||
if q.Scope == "project" {
|
||||
projectID = m.projectID
|
||||
}
|
||||
list, err := m.st.ListMemory(m.owner, q.Scope, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = memory.MaxRecallEntries
|
||||
}
|
||||
out := make([]memory.Entry, 0, len(list))
|
||||
for _, e := range list {
|
||||
out = append(out, memory.Entry{
|
||||
ID: e.ID, Scope: e.Scope, ProjectID: string(e.ProjectID), Type: e.Type,
|
||||
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
|
||||
CreatedAt: e.CreatedAt, ExpiresAt: e.ExpiresAt,
|
||||
})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StoreMemory 是 runCtx 的端口出口(llm_direct 经接口断言取用)。
|
||||
func (c *runCtx) StoreMemory() memory.Store {
|
||||
if c.mgr == nil || c.mgr.st == nil {
|
||||
return nil
|
||||
}
|
||||
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
|
||||
}
|
||||
|
||||
// Append 写回一条(project scope 用适配器的项目上下文)。
|
||||
func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
|
||||
projectID := e.ProjectID
|
||||
if e.Scope == "project" && projectID == "" {
|
||||
projectID = m.projectID
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
created := e.CreatedAt
|
||||
if created.IsZero() {
|
||||
created = now
|
||||
}
|
||||
_, err := m.st.PutMemory(m.owner, core.MemoryEntry{
|
||||
ID: e.ID, Scope: e.Scope, ProjectID: core.ID(projectID), Type: e.Type,
|
||||
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
|
||||
CreatedAt: created, ExpiresAt: e.ExpiresAt,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/memory"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- N3 WP5:storeMemory 适配器 ----
|
||||
|
||||
func openMemStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestStoreMemoryRecallScopeAndLimit(t *testing.T) {
|
||||
st := openMemStore(t)
|
||||
put := func(scope, project, typ, content string) {
|
||||
t.Helper()
|
||||
if _, err := st.PutMemory("u1", core.MemoryEntry{Scope: scope, ProjectID: core.ID(project), Type: typ, Content: content, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put("user", "", "fact", "用户事实")
|
||||
put("project", "p1", "decision", "项目决定")
|
||||
put("project", "p2", "decision", "别的项目")
|
||||
m := NewStoreMemory(st, "u1", "p1")
|
||||
// 空 scope = 全量(迁移前 Memories() 语义)。
|
||||
all, err := m.Recall(context.Background(), memory.RecallQuery{})
|
||||
if err != nil || len(all) != 3 {
|
||||
t.Fatalf("全量召回:%d %v", len(all), err)
|
||||
}
|
||||
// user scope。
|
||||
users, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "user"})
|
||||
if len(users) != 1 || users[0].Content != "用户事实" {
|
||||
t.Fatalf("user scope:%+v", users)
|
||||
}
|
||||
// project scope 用适配器的项目上下文(p1,不含 p2)。
|
||||
projs, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "project"})
|
||||
if len(projs) != 1 || projs[0].Content != "项目决定" {
|
||||
t.Fatalf("project scope:%+v", projs)
|
||||
}
|
||||
// Limit。
|
||||
limited, _ := m.Recall(context.Background(), memory.RecallQuery{Limit: 2})
|
||||
if len(limited) != 2 {
|
||||
t.Fatalf("Limit:%d", len(limited))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMemoryAppendRoundtrip(t *testing.T) {
|
||||
st := openMemStore(t)
|
||||
m := NewStoreMemory(st, "u1", "p1")
|
||||
future := time.Now().Add(48 * time.Hour)
|
||||
if err := m.Append(context.Background(), memory.Entry{
|
||||
Scope: "project", Type: "procedure", Content: "先跑 lint", Source: "agent", ExpiresAt: &future,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := st.ListMemory("u1", "project", "p1")
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("回读:%d %v", len(list), err)
|
||||
}
|
||||
got := list[0]
|
||||
if got.Type != "procedure" || got.Content != "先跑 lint" || got.Source != "agent" ||
|
||||
got.ProjectID != "p1" || got.ExpiresAt == nil {
|
||||
t.Fatalf("写入形态:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// runCtx 出口:同一 store 上的 runCtx 暴露同一端口语义。
|
||||
func TestRunCtxStoreMemory(t *testing.T) {
|
||||
c, st := newSpawnEnv(t, &gateRunner{})
|
||||
if _, err := st.PutMemory(c.run.Owner, core.MemoryEntry{Scope: "user", Type: "fact", Content: "跨会话事实", CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := c.StoreMemory()
|
||||
if port == nil {
|
||||
t.Fatal("StoreMemory 必须非空")
|
||||
}
|
||||
entries, err := port.Recall(context.Background(), memory.RecallQuery{})
|
||||
if err != nil || len(entries) != 1 || entries[0].Content != "跨会话事实" {
|
||||
t.Fatalf("runCtx 召回:%+v %v", entries, err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentpolicy "agent-native/runtime/policy"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -36,3 +40,51 @@ func TestDefaultModeStillRunsWrite(t *testing.T) {
|
||||
t.Fatalf("default write: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvAllowStoreKeysByName:SessionAllows 端口的键控口径保持「会话 × 工具名」,
|
||||
// 不区分参数——与旧 sessionAllows 布尔参数的行为一致。
|
||||
func TestConvAllowStoreKeysByName(t *testing.T) {
|
||||
c, _ := newExecEnv(t)
|
||||
conv := c.run.ConversationID
|
||||
store := convAllowStore{mgr: c.mgr, conv: conv}
|
||||
pol := agentpolicy.Policy{
|
||||
Rules: []agentpolicy.Rule{{Effect: agentpolicy.EffectAsk, Tool: "run_shell"}},
|
||||
SessionAllows: store,
|
||||
}
|
||||
if got := pol.Decide("run_shell", `{"command":"ls"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAsk {
|
||||
t.Fatalf("before remember: %q, want ask", got)
|
||||
}
|
||||
store.Remember("run_shell", `{"command":"ls"}`)
|
||||
if got := pol.Decide("run_shell", `{"command":"rm -rf /tmp/x"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAllow {
|
||||
t.Fatalf("after remember, other args: %q, want allow(按名放行)", got)
|
||||
}
|
||||
if !c.mgr.sessionAllows(conv, "run_shell") {
|
||||
t.Fatal("remember did not reach the manager table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionAskRoutesToHITL:内核 ask 决策经 HITL 确认卡裁决;
|
||||
// session 选项落会话放行,取消给出可读理由。
|
||||
func TestPermissionAskRoutesToHITL(t *testing.T) {
|
||||
c, _ := newExecEnv(t)
|
||||
c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
|
||||
call := agentllm.ToolCall{ID: "c1", Name: "run_shell", Arguments: `{"command":"ls"}`}
|
||||
|
||||
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "yes"}}
|
||||
if v := c.PermissionAsk(context.Background(), call); !v.Allow {
|
||||
t.Fatalf("yes verdict = %+v", v)
|
||||
}
|
||||
|
||||
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "session"}}
|
||||
if v := c.PermissionAsk(context.Background(), call); !v.Allow {
|
||||
t.Fatalf("session verdict = %+v", v)
|
||||
}
|
||||
if !c.mgr.sessionAllows(c.run.ConversationID, "run_shell") {
|
||||
t.Fatal("session choice did not remember the allow")
|
||||
}
|
||||
|
||||
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "no"}}
|
||||
if v := c.PermissionAsk(context.Background(), call); v.Allow || v.Reason == "" {
|
||||
t.Fatalf("no verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package runmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -75,14 +76,18 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
|
||||
if err != nil {
|
||||
return checkpoint.Result{}, err
|
||||
}
|
||||
log, err := m.OpenCheckpoints(owner, convID)
|
||||
cpLog, err := m.OpenCheckpoints(owner, convID)
|
||||
if err != nil {
|
||||
return checkpoint.Result{}, err
|
||||
}
|
||||
res, err := log.Rewind(messageID, ordered)
|
||||
res, err := cpLog.Rewind(messageID, ordered)
|
||||
if err != nil {
|
||||
return checkpoint.Result{}, err
|
||||
}
|
||||
// 保留策略(N3 WP3):成功的回滚后按 anchor 数裁到 200,日志有界。
|
||||
if cerr := cpLog.Compact(200); cerr != nil {
|
||||
log.Printf("[runmgr] checkpoint compact %s: %v", convID, cerr)
|
||||
}
|
||||
note := rewindNote(res)
|
||||
msg := core.Message{
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
|
||||
+276
-35
@@ -5,6 +5,7 @@ package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -15,14 +16,18 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
"agent-native/runtime/filestate"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentpolicy "agent-native/runtime/policy"
|
||||
"agent-native/runtime/subagent"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/ingest"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/route"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
@@ -42,15 +47,20 @@ type Manager struct {
|
||||
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
|
||||
mockSub runtime.Runner // 子代理脚本回放
|
||||
squad runtime.Runner // runtime.entry=squad 时的对话入口
|
||||
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.WorkflowRunner
|
||||
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.CompiledWorkflowRunner
|
||||
workflows *workflow.Registry // run_workflow 按 id 查找已注册定义
|
||||
gate IntentGate
|
||||
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
|
||||
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
|
||||
OnUsage func(owner string, tokens int)
|
||||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||
commands CommandSource
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
|
||||
workspaceRoot string
|
||||
|
||||
@@ -78,6 +88,7 @@ const maxBackgroundChildren = 3
|
||||
|
||||
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
|
||||
type bgNotice struct {
|
||||
id string // 持久化队列主键(重启重投凭据)
|
||||
owner string
|
||||
name string
|
||||
payload core.AgentResultPayload
|
||||
@@ -87,15 +98,19 @@ type bgNotice 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
|
||||
promptTokens int
|
||||
completionTokens int
|
||||
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
|
||||
lastPromptTokens int
|
||||
lastCompletionTokens int
|
||||
cacheReadTokens int
|
||||
cacheWriteTokens int
|
||||
}
|
||||
|
||||
type hitlAnswer struct {
|
||||
@@ -144,6 +159,15 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve
|
||||
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
|
||||
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
|
||||
|
||||
// SetSharedKnowledgeAllow 注入回合内共享检索谓词(EXT-13;组合根在装载扩展后
|
||||
// 以同一 EXT-8 适配器槽接线)。nil = 现状。
|
||||
func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.ID) bool) {
|
||||
m.sharedKnowledgeAllow = fn
|
||||
}
|
||||
|
||||
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
|
||||
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
|
||||
|
||||
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
|
||||
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||||
|
||||
@@ -151,6 +175,46 @@ func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||||
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
|
||||
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
|
||||
|
||||
// CommandSource EXT-14:命令总线暴露给回合执行的两个面——工具投影清单与
|
||||
// 经命令总的分发(同审计信封与 HITL 收口)。runmgr 只认本包视图类型,
|
||||
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
|
||||
type CommandSource interface {
|
||||
CommandTools() []CommandTool
|
||||
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
|
||||
}
|
||||
|
||||
// CommandTool EXT-14 工具投影视图:组合根从命令总线的投影清单转换注入。
|
||||
type CommandTool struct {
|
||||
ID string
|
||||
Description string
|
||||
Params json.RawMessage
|
||||
Risk string
|
||||
}
|
||||
|
||||
// CommandRequest 一次命令工具分发请求(runmgr 固定以 Entrance=agent 发起,
|
||||
// Actor/Owner 绑定会话 owner、带会话上下文)。
|
||||
type CommandRequest struct {
|
||||
ID string
|
||||
Params map[string]any
|
||||
Entrance string
|
||||
Actor string
|
||||
Owner string
|
||||
ConversationID string
|
||||
}
|
||||
|
||||
// CommandResponse 分发结果视图:回执状态/原因 + 负载 + 是否挂起确认卡。
|
||||
type CommandResponse struct {
|
||||
State string
|
||||
Reason string
|
||||
Payload any
|
||||
Card bool
|
||||
}
|
||||
|
||||
// sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。
|
||||
type sharedRetriever interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}
|
||||
|
||||
// ---- runCtx 实现(runtime.RunCtx)----
|
||||
|
||||
type runCtx struct {
|
||||
@@ -231,10 +295,15 @@ func (c *runCtx) ConversationSandbox() string {
|
||||
}
|
||||
|
||||
// AddUsage 累加本 run 的模型用量,finishRun 写入 run.Usage。
|
||||
// 计费拆分(N3 WP6):Last* 覆盖(最近一次请求)、Cache* 累计。
|
||||
func (c *runCtx) AddUsage(u core.Usage) {
|
||||
c.mgr.mu.Lock()
|
||||
c.h.promptTokens += u.PromptTokens
|
||||
c.h.completionTokens += u.CompletionTokens
|
||||
c.h.lastPromptTokens = u.LastPromptTokens
|
||||
c.h.lastCompletionTokens = u.LastCompletionTokens
|
||||
c.h.cacheReadTokens += u.CacheReadTokens
|
||||
c.h.cacheWriteTokens += u.CacheWriteTokens
|
||||
c.mgr.mu.Unlock()
|
||||
}
|
||||
func (c *runCtx) Subtask() string { return c.subTask }
|
||||
@@ -286,8 +355,73 @@ func (m *Manager) sessionAllows(conv, tool string) bool {
|
||||
return m.sessionAllow[conv][tool]
|
||||
}
|
||||
|
||||
// convAllowStore 把 Manager 的会话放行表呈现为 agentpolicy.SessionAllows 端口。
|
||||
// 键控保持既有口径:会话 × 工具名(同名调用不再询问),不区分参数。
|
||||
type convAllowStore struct {
|
||||
mgr *Manager
|
||||
conv string
|
||||
}
|
||||
|
||||
func (s convAllowStore) Remember(name, args string) {
|
||||
s.mgr.rememberSessionAllow(s.conv, name)
|
||||
}
|
||||
|
||||
func (s convAllowStore) Allowed(name, args string) bool {
|
||||
return s.mgr.sessionAllows(s.conv, name)
|
||||
}
|
||||
|
||||
// toolConfirmCard 是工具确认 HITL 卡的统一形态:ExecuteTool 的画像确认与
|
||||
// 内核 PermissionAsk 端口共用,两条路径的选项与口径保持一致。
|
||||
func toolConfirmCard(name, args string) core.HITLCard {
|
||||
var argsPreview string
|
||||
if len(args) > 0 {
|
||||
argsPreview = truncate(args, 600)
|
||||
}
|
||||
return core.HITLCard{
|
||||
Question: fmt.Sprintf("执行工具 %s?", name), Kind: core.HITLKindConfirm, Detail: argsPreview,
|
||||
Choices: []core.HITLChoice{
|
||||
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
|
||||
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
|
||||
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
|
||||
},
|
||||
AllowFreeInput: false,
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionAsk 把内核钩子的 ask 决策路由到 HITL 确认卡(N1 WP2 宿主侧)。
|
||||
// 「本会话始终允许」沿用 ExecuteTool 的会话放行口径(会话 × 工具名)。
|
||||
func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
|
||||
resp, err := c.AskHITL(toolConfirmCard(call.Name, call.Arguments))
|
||||
if err != nil {
|
||||
return nruntime.AskVerdict{Reason: "hitl aborted: " + err.Error()}
|
||||
}
|
||||
switch resp.ChoiceID {
|
||||
case "session":
|
||||
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
|
||||
return nruntime.AskVerdict{Allow: true}
|
||||
case "yes":
|
||||
return nruntime.AskVerdict{Allow: true}
|
||||
default:
|
||||
return nruntime.AskVerdict{Reason: "用户取消了本次工具调用"}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *runCtx) Tools() []core.ToolDesc {
|
||||
list := c.mgr.registry.List()
|
||||
list := tools.FilterExecDisabled(c.mgr.registry.List())
|
||||
if c.mgr.commands != nil {
|
||||
// EXT-14:exposeAsTool 命令投影为工具(去重:注册表同名工具优先)。
|
||||
seen := make(map[string]bool, len(list))
|
||||
for _, t := range list {
|
||||
seen[t.Name] = true
|
||||
}
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
if seen[ct.ID] {
|
||||
continue
|
||||
}
|
||||
list = append(list, core.ToolDesc{Name: ct.ID, Description: ct.Description,
|
||||
Parameters: ct.Params, Source: core.SourcePlugin, Risk: ct.Risk})
|
||||
}
|
||||
}
|
||||
out := make([]core.ToolDesc, 0, len(list))
|
||||
fs := c.eff.GetBool("runtime.fsTools", false)
|
||||
code := c.eff.GetBool("runtime.codeOrchestration", false)
|
||||
@@ -666,6 +800,13 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
q.ProjectID = c.run.ProjectID
|
||||
}
|
||||
if c.mgr.retriever != nil {
|
||||
if c.mgr.sharedKnowledgeAllow != nil {
|
||||
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
|
||||
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
|
||||
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
|
||||
}
|
||||
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||||
@@ -678,29 +819,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
|
||||
}
|
||||
desc, _, _ := c.mgr.registry.Get(call.Name)
|
||||
effect := agentpolicy.Decide(agentpolicy.Request{
|
||||
Mode: agentpolicy.Normalize(c.permissionMode), Name: call.Name, Args: string(call.Arguments),
|
||||
Traits: agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk},
|
||||
SessionAllow: c.mgr.sessionAllows(c.run.ConversationID, call.Name),
|
||||
}, nil)
|
||||
// N1 WP3:判定走 Policy 对象,会话放行经 SessionAllows 端口(键控口径不变:会话 × 工具名)。
|
||||
pol := agentpolicy.Policy{
|
||||
Mode: agentpolicy.Normalize(c.permissionMode),
|
||||
SessionAllows: convAllowStore{mgr: c.mgr, conv: c.run.ConversationID},
|
||||
}
|
||||
effect := pol.Decide(call.Name, string(call.Arguments),
|
||||
agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk})
|
||||
if effect == agentpolicy.EffectDeny {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("计划模式拒绝了工具 %s", call.Name)}
|
||||
}
|
||||
if effect != agentpolicy.EffectAllow && (effect == agentpolicy.EffectAsk || policy.NeedsConfirm(call.Name)) {
|
||||
var argsPreview string
|
||||
if len(call.Arguments) > 0 {
|
||||
argsPreview = truncate(string(call.Arguments), 600)
|
||||
}
|
||||
card := core.HITLCard{
|
||||
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm, Detail: argsPreview,
|
||||
Choices: []core.HITLChoice{
|
||||
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
|
||||
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
|
||||
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
|
||||
},
|
||||
AllowFreeInput: false,
|
||||
}
|
||||
resp, err := c.AskHITL(card)
|
||||
resp, err := c.AskHITL(toolConfirmCard(call.Name, string(call.Arguments)))
|
||||
if err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
|
||||
}
|
||||
@@ -710,6 +840,33 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||||
}
|
||||
}
|
||||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||||
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
|
||||
if c.mgr.commands != nil {
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
if ct.ID != call.Name {
|
||||
continue
|
||||
}
|
||||
var params map[string]any
|
||||
if len(call.Arguments) > 0 {
|
||||
_ = json.Unmarshal(call.Arguments, ¶ms)
|
||||
}
|
||||
resp := c.mgr.commands.DispatchCommand(c.ctx, CommandRequest{
|
||||
ID: call.Name, Params: params, Entrance: "agent",
|
||||
Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID,
|
||||
})
|
||||
out, _ := json.Marshal(resp.Payload)
|
||||
if resp.Card && resp.State == "accepted" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "ok",
|
||||
Output: "已创建确认卡,等待用户确认后执行:" + resp.Reason}
|
||||
}
|
||||
if resp.State != "committed" && resp.State != "accepted" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error",
|
||||
Output: "命令 " + call.Name + " " + resp.State + ": " + resp.Reason}
|
||||
}
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: string(out)}
|
||||
}
|
||||
}
|
||||
tc := &toolCtx{rc: c, callID: call.CallID}
|
||||
res := c.mgr.registry.Execute(tc, call)
|
||||
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
|
||||
@@ -866,7 +1023,7 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo
|
||||
if m.wfRunner != nil {
|
||||
return m.wfRunner
|
||||
}
|
||||
return runtime.WorkflowRunner{}
|
||||
return runtime.CompiledWorkflowRunner{}
|
||||
}
|
||||
if eff.GetBool("runtime.mock", false) {
|
||||
return m.mock
|
||||
@@ -985,6 +1142,8 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
|
||||
runner := selectConversationRunner(m, eff, launch)
|
||||
inputPaths, _ := m.stageInputs(conv, eff, inputs)
|
||||
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
||||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||||
go m.execute(rc, runner)
|
||||
return run, nil
|
||||
@@ -1035,6 +1194,10 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
usage.PromptTokens = rc.h.promptTokens
|
||||
usage.CompletionTokens = rc.h.completionTokens
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
usage.LastPromptTokens = rc.h.lastPromptTokens
|
||||
usage.LastCompletionTokens = rc.h.lastCompletionTokens
|
||||
usage.CacheReadTokens = rc.h.cacheReadTokens
|
||||
usage.CacheWriteTokens = rc.h.cacheWriteTokens
|
||||
m.mu.Unlock()
|
||||
if m.shuttingDown.Load() && status == core.RunCancelled {
|
||||
status = core.RunInterrupted
|
||||
@@ -1227,12 +1390,21 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
answer = resp.FreeText
|
||||
trimmed := strings.TrimSpace(resp.FreeText)
|
||||
// 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。
|
||||
// 命中与否经唯一 Router(internal/route)判定,与 chat 路由同一裁决(I1 WP3)。
|
||||
pivot := false
|
||||
pivotReason := ""
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
pivot, pivotReason = true, "command"
|
||||
} else if m.gate != nil {
|
||||
if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
|
||||
if rec, ok := m.gate.(interface {
|
||||
Recognize(ctx context.Context, input string) intentgate.MatchResult
|
||||
}); ok {
|
||||
res := rec.Recognize(context.Background(), trimmed)
|
||||
dec := route.Decide(res, route.Env{})
|
||||
if dec.StartFound {
|
||||
pivot, pivotReason = true, "intent:"+res.ID
|
||||
}
|
||||
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
|
||||
pivot, pivotReason = true, "intent:"+topID
|
||||
}
|
||||
}
|
||||
@@ -1303,6 +1475,8 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
|
||||
if err := m.st.CreateRun(&child); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。
|
||||
m.writeSubagentRequest(child, agentID, name, task, hint, detach)
|
||||
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
|
||||
|
||||
base := parentCtx
|
||||
@@ -1339,7 +1513,17 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
|
||||
return runner.Execute(rc)
|
||||
}()
|
||||
|
||||
// 子代理记账(N3 WP6):token 用量随子 run 入账(此前只有 DurationMs)。
|
||||
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
|
||||
m.mu.Lock()
|
||||
usage.PromptTokens = rc.h.promptTokens
|
||||
usage.CompletionTokens = rc.h.completionTokens
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
usage.LastPromptTokens = rc.h.lastPromptTokens
|
||||
usage.LastCompletionTokens = rc.h.lastCompletionTokens
|
||||
usage.CacheReadTokens = rc.h.cacheReadTokens
|
||||
usage.CacheWriteTokens = rc.h.cacheWriteTokens
|
||||
m.mu.Unlock()
|
||||
status := core.RunCompleted
|
||||
summary := "完成"
|
||||
payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary}
|
||||
@@ -1368,6 +1552,8 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
|
||||
m.mu.Lock()
|
||||
subParts := append([]core.Part{}, rc.h.assistant...)
|
||||
m.mu.Unlock()
|
||||
// 独立转录落定(含终态 done 标记,供续跑幂等判定)。
|
||||
m.writeSubagentTranscript(child, subParts, string(status))
|
||||
fullText := partsText(subParts)
|
||||
if len(subParts) > 0 {
|
||||
_ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
|
||||
@@ -1432,6 +1618,15 @@ func (m *Manager) decBackground(convID string) {
|
||||
}
|
||||
|
||||
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
|
||||
// 先落库(重启重投凭据),再进内存热队列;落库失败不丢内存路径。
|
||||
if n.id == "" {
|
||||
n.id = store.NewID("ntc")
|
||||
}
|
||||
if err := m.st.AppendNotice(store.Notice{
|
||||
ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload,
|
||||
}); err != nil {
|
||||
log.Printf("[runmgr] notice persist %s: %v", convID, err)
|
||||
}
|
||||
m.noticeMu.Lock()
|
||||
defer m.noticeMu.Unlock()
|
||||
if m.notices == nil {
|
||||
@@ -1440,6 +1635,31 @@ func (m *Manager) enqueueNotice(convID string, n bgNotice) {
|
||||
m.notices[convID] = append(m.notices[convID], n)
|
||||
}
|
||||
|
||||
// LoadPendingNotices 启动时把未投递的通知装回内存队列并尝试投递
|
||||
// (N2 WP5:重启重投)。投递遵循既有空闲语义(忙则等下一轮触发)。
|
||||
func (m *Manager) LoadPendingNotices() {
|
||||
items, err := m.st.ListNotices()
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] notice load: %v", err)
|
||||
return
|
||||
}
|
||||
byConv := map[string]bool{}
|
||||
m.noticeMu.Lock()
|
||||
if m.notices == nil {
|
||||
m.notices = map[string][]bgNotice{}
|
||||
}
|
||||
for _, it := range items {
|
||||
m.notices[it.ConversationID] = append(m.notices[it.ConversationID], bgNotice{
|
||||
id: it.ID, owner: it.Owner, name: it.Name, payload: it.Payload,
|
||||
})
|
||||
byConv[it.ConversationID] = true
|
||||
}
|
||||
m.noticeMu.Unlock()
|
||||
for convID := range byConv {
|
||||
m.deliverNotices(convID)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverNotices 在会话空闲时把全部待投递结果合并成一条 system 消息,并启动新的父回合。
|
||||
// 会话忙、或启动时发现忙,就把结果放回队列。
|
||||
func (m *Manager) deliverNotices(convID string) {
|
||||
@@ -1484,6 +1704,17 @@ func (m *Manager) deliverNotices(convID string) {
|
||||
}
|
||||
m.notices[convID] = append(items, m.notices[convID]...)
|
||||
m.noticeMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 投递成功才删持久化行(失败保留下次重投)。
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, n := range items {
|
||||
if n.id != "" {
|
||||
ids = append(ids, n.id)
|
||||
}
|
||||
}
|
||||
if err := m.st.DeleteNotices(ids); err != nil {
|
||||
log.Printf("[runmgr] notice delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1544,6 +1775,16 @@ func (m *Manager) InterruptRunning() {
|
||||
h.cancel()
|
||||
}
|
||||
for _, id := range ids {
|
||||
// 子代理 run 先把已收集的部分转录落定(中断可续跑的现场)。
|
||||
if run, err := m.st.GetRunByID(id); err == nil && run.Kind == core.RunSubagent {
|
||||
m.mu.Lock()
|
||||
var parts []core.Part
|
||||
if h, ok := m.active[id]; ok {
|
||||
parts = append(parts, h.assistant...)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.writeSubagentTranscript(run, parts, "interrupted")
|
||||
}
|
||||
m.sealRunInterrupted(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package runmgr
|
||||
|
||||
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):回合内检索(knowledge_search
|
||||
// 工具与 @knowledge 注入的同一入口 runCtx.Retrieve)在适配器放行后吃到共享命中;
|
||||
// 未放行/未注册谓词时保持本人两路、零泄露。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type sharedFakeRetriever struct {
|
||||
own []core.RetrievalHit
|
||||
shared []core.RetrievalHit
|
||||
gotAlt bool
|
||||
}
|
||||
|
||||
func (f *sharedFakeRetriever) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
return f.own, nil
|
||||
}
|
||||
|
||||
func (f *sharedFakeRetriever) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||||
f.gotAlt = true
|
||||
out := append([]core.RetrievalHit{}, f.own...) // 对齐 rag.Pipeline:本人两路如常
|
||||
for _, h := range f.shared {
|
||||
if allow(h.Doc.Owner, h.Doc.ProjectID) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestRunContextRetrieveShared(t *testing.T) {
|
||||
m := New(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
fr := &sharedFakeRetriever{
|
||||
own: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "own-1", Chunk: "本人命中"}}},
|
||||
shared: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "sh-1", Chunk: "共享命中", Owner: "alice", ProjectID: "p-1"}}},
|
||||
}
|
||||
m.SetRetriever(fr)
|
||||
rc := &runCtx{mgr: m, run: core.Run{Owner: "bob"}}
|
||||
|
||||
// 未注册共享谓词:本人两路如常,不触碰共享通道。
|
||||
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
|
||||
t.Fatalf("no predicate: hits=%+v err=%v", hits, err)
|
||||
}
|
||||
if fr.gotAlt {
|
||||
t.Fatal("no predicate: must not call RetrieveShared(未注册=现状)")
|
||||
}
|
||||
|
||||
// 注册谓词(放行 bob×p-1)→ 本人 + 共享。
|
||||
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
return owner == "bob" && string(projectID) == "p-1"
|
||||
})
|
||||
fr.gotAlt = false
|
||||
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 2 {
|
||||
t.Fatalf("with predicate: hits=%+v err=%v, want own+shared", hits, err)
|
||||
}
|
||||
if !fr.gotAlt {
|
||||
t.Fatal("with predicate: RetrieveShared not consulted(EXT-13)")
|
||||
}
|
||||
|
||||
// 谓词拒绝 → 只剩本人。
|
||||
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { return false })
|
||||
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
|
||||
t.Fatalf("denied: hits=%+v err=%v, want own only", hits, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- N2 WP5:子代理独立转录、中断续跑、通知持久化 ----
|
||||
|
||||
// 派生即落 request.json;完成时 transcript.jsonl + done.json 落定。
|
||||
func TestSubagentTranscriptFilesWritten(t *testing.T) {
|
||||
c, _ := newSpawnEnv(t, &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "结果文本"}}})
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
res, err := c.SpawnSubagent("检索", "查资料", "提示语")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(ws, c.run.Owner, c.run.ConversationID, "subagents", res.AgentRun)
|
||||
reqBody, err := os.ReadFile(filepath.Join(dir, "request.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("request.json: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(reqBody), "查资料") {
|
||||
t.Fatalf("request.json 内容:%s", reqBody)
|
||||
}
|
||||
trBody, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl"))
|
||||
if err != nil || !strings.Contains(string(trBody), "结果文本") {
|
||||
t.Fatalf("transcript.jsonl:%v %s", err, trBody)
|
||||
}
|
||||
doneBody, err := os.ReadFile(filepath.Join(dir, "done.json"))
|
||||
if err != nil || !strings.Contains(string(doneBody), "completed") {
|
||||
t.Fatalf("done.json:%v %s", err, doneBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 中断子 run 凭 request.json 续跑:同任务重派、hint 带前次转录路径、
|
||||
// resumed.json 防重复续跑。
|
||||
func TestResumeInterruptedChildrenRespawns(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
|
||||
// 父 run 落库(续跑要经 store 取回派生上下文)。
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 造一个中断的子 run 现场(等价于崩溃前的派生)。
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
|
||||
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
|
||||
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, err := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
|
||||
ids, err := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != len(before)+1 {
|
||||
t.Fatalf("续跑应新增一个子 run:before=%v after=%v", before, ids)
|
||||
}
|
||||
// resumed.json 幂等:第二次调用不再新增。
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids2) != len(ids) {
|
||||
t.Fatalf("重复续跑:%v → %v", ids, ids2)
|
||||
}
|
||||
}
|
||||
|
||||
// 通知先落库后投递;模拟重启后 LoadPendingNotices 重投并清库。
|
||||
func TestNoticesPersistAndRedeliverAfterRestart(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "后台结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
// 会话占线:通知只能入库不能投递。
|
||||
if err := c.mgr.claimConversation(c.run.ConversationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.SpawnBackground("agent.sub.general", "甲", "任务甲", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUntil(t, func() bool {
|
||||
items, err := st.ListNotices()
|
||||
return err == nil && len(items) == 1
|
||||
})
|
||||
// 模拟重启:同一 store 上的新 Manager 只接通知面(会话仍忙时不应丢)。
|
||||
c.mgr.releaseConversation(c.run.ConversationID)
|
||||
m2 := New(st, c.mgr.bus, nil, c.mgr.profiles, c.mgr.settings, noopRunner{}, runner, nil, nil)
|
||||
m2.LoadPendingNotices()
|
||||
waitUntil(t, func() bool {
|
||||
items, err := st.ListNotices()
|
||||
return err == nil && len(items) == 0
|
||||
})
|
||||
texts := systemTexts(t, c)
|
||||
if len(texts) == 0 || !strings.Contains(texts[len(texts)-1], "后台结果") {
|
||||
t.Fatalf("重投的通知正文缺失:%v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
// 适配层 Background 分支:Request.Background=true → SpawnBackground(语义
|
||||
// 与产品 delegate 的 background 参数一致)。在本包内用 runCtx 直验。
|
||||
func TestSpawnBackgroundRecordsDetach(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "bg"}}}
|
||||
c, _ := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
id, err := c.SpawnBackground("agent.sub.general", "后台", "任务", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, err := c.mgr.st.GetRunByID(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Kind != core.RunSubagent || run.Status != core.RunCompleted && run.Status != core.RunRunning {
|
||||
t.Fatalf("run = %+v", run)
|
||||
}
|
||||
}
|
||||
|
||||
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
|
||||
// completed 终态挡续跑。
|
||||
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
|
||||
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
|
||||
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
|
||||
// 模拟优雅停机:partial 转录 + done.json{status:"interrupted"}。
|
||||
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "半截现场"}}, "interrupted")
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids) != len(before)+1 {
|
||||
t.Fatalf("优雅停机现场应被续跑一次:before=%v after=%v", before, ids)
|
||||
}
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids2) != len(ids) {
|
||||
t.Fatalf("重复续跑:%v → %v", ids, ids2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeSkipsCompletedMarker(t *testing.T) {
|
||||
c, st := newSpawnEnv(t, &gateRunner{})
|
||||
c.mgr.runner = noopRunner{}
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
|
||||
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
|
||||
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "", true)
|
||||
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "已完成"}}, "completed")
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids) != len(before) {
|
||||
t.Fatalf("completed 终态不得续跑:before=%v after=%v", before, ids)
|
||||
}
|
||||
}
|
||||
|
||||
// N3 WP6:子代理记账链——AddUsage 累计拆分字段,finishChild 合并进 child.Usage。
|
||||
func TestAddUsageAccumulatesSplitFields(t *testing.T) {
|
||||
c, _ := newSpawnEnv(t, &gateRunner{})
|
||||
c.h = &runHandle{}
|
||||
c.AddUsage(core.Usage{PromptTokens: 100, CompletionTokens: 10, LastPromptTokens: 100, LastCompletionTokens: 10, CacheReadTokens: 50, CacheWriteTokens: 4})
|
||||
c.AddUsage(core.Usage{PromptTokens: 60, CompletionTokens: 6, LastPromptTokens: 60, LastCompletionTokens: 6, CacheReadTokens: 20, CacheWriteTokens: 2})
|
||||
c.mgr.mu.Lock()
|
||||
defer c.mgr.mu.Unlock()
|
||||
if c.h.promptTokens != 160 || c.h.completionTokens != 16 ||
|
||||
c.h.lastPromptTokens != 60 || c.h.lastCompletionTokens != 6 ||
|
||||
c.h.cacheReadTokens != 70 || c.h.cacheWriteTokens != 6 {
|
||||
t.Fatalf("记账链:%+v", c.h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// subagent_transcript.go — 子代理独立转录与中断续跑(N2 WP5)。
|
||||
// 每个子 run 一个目录:<workspaceRoot>/<owner>/<conv>/subagents/<runID>/
|
||||
// request.json 派生请求(agentID/name/task/hint/detach),续跑的重建凭据
|
||||
// transcript.jsonl 按 part 追加的转录(完成/中断时落定)
|
||||
// done.json 终态标记(resume 幂等:有它不再续跑)
|
||||
// resumed.json 已续跑标记(指向新 run)
|
||||
|
||||
// subagentDir 返回子 run 的转录目录(未配置 workspaceRoot 时为空,写盘静默跳过)。
|
||||
func (m *Manager) subagentDir(owner, convID, runID string) string {
|
||||
if m.workspaceRoot == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(m.workspaceRoot, filepath.Clean(owner), filepath.Clean(convID), "subagents", filepath.Clean(runID))
|
||||
}
|
||||
|
||||
// subagentRequest 是 request.json 的内容。
|
||||
type subagentRequest struct {
|
||||
AgentID string `json:"agentId"`
|
||||
Name string `json:"name"`
|
||||
Task string `json:"task"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Detach bool `json:"detach"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志,
|
||||
// 不阻断派生——转录是辅助面)。
|
||||
func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) {
|
||||
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
log.Printf("[runmgr] subagent dir %s: %v", run.ID, err)
|
||||
return
|
||||
}
|
||||
body, _ := json.MarshalIndent(subagentRequest{
|
||||
AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil {
|
||||
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
|
||||
}
|
||||
// 转录文件先建(append 语义清晰可见)。
|
||||
if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil {
|
||||
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。
|
||||
// status 是终态(completed/failed/cancelled/interrupted)。
|
||||
func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) {
|
||||
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] transcript open %s: %v", run.ID, err)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
for i, p := range parts {
|
||||
_ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)})
|
||||
}
|
||||
_ = f.Close()
|
||||
done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)})
|
||||
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
|
||||
log.Printf("[runmgr] done marker %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理——
|
||||
// 缺标记本身就是中断现场)。
|
||||
func doneStatus(dir string) string {
|
||||
body, err := os.ReadFile(filepath.Join(dir, "done.json"))
|
||||
if err != nil {
|
||||
return "interrupted"
|
||||
}
|
||||
var done struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if json.Unmarshal(body, &done) != nil || done.Status == "" {
|
||||
return "interrupted"
|
||||
}
|
||||
return done.Status
|
||||
}
|
||||
|
||||
// readSubagentRequest 读 request.json(无文件返回 nil, nil)。
|
||||
func readSubagentRequest(dir string) (*subagentRequest, error) {
|
||||
body, err := os.ReadFile(filepath.Join(dir, "request.json"))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var req subagentRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, fmt.Errorf("request.json: %w", err)
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// ResumeInterruptedChildren 重启后扫描被中断的子代理 run:有派生请求且
|
||||
// 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint
|
||||
// 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。
|
||||
func (m *Manager) ResumeInterruptedChildren() {
|
||||
runs, err := m.st.ListInterruptedSubagentRuns()
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] resume scan: %v", err)
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
// done.json 语义分流(N3 WP1):终态(completed/failed/cancelled)挡续跑;
|
||||
// interrupted 不挡——优雅停机与崩溃都是可续跑现场。
|
||||
if doneStatus(dir) != "interrupted" || fileExists(filepath.Join(dir, "resumed.json")) {
|
||||
continue
|
||||
}
|
||||
req, err := readSubagentRequest(dir)
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] resume read %s: %v", run.ID, err)
|
||||
continue
|
||||
}
|
||||
if req == nil {
|
||||
continue // WP5 之前的旧 run 没有重建凭据,保持 interrupted 终态
|
||||
}
|
||||
parent, err := m.st.GetRunByID(run.ParentRunID)
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] resume parent %s: %v", run.ParentRunID, err)
|
||||
continue
|
||||
}
|
||||
hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " +
|
||||
filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)"
|
||||
newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint)
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] resume spawn %s: %v", run.ID, err)
|
||||
continue
|
||||
}
|
||||
marker, _ := json.Marshal(map[string]string{
|
||||
"newRun": newID,
|
||||
"resumedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
|
||||
log.Printf("[runmgr] resumed marker %s: %v", run.ID, err)
|
||||
}
|
||||
log.Printf("[runmgr] resumed subagent %s as %s", run.ID, newID)
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func (st *directRun) composeUserPrompt(draft, notice string) string {
|
||||
}
|
||||
b.WriteString("\n交付物:\n")
|
||||
names := 0
|
||||
for _, ref := range st.deliverables {
|
||||
for _, ref := range st.latestDeliverables() {
|
||||
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 窗口按 token 配置并换算为字符阈值;此前 32000 字符硬默认会让长材料任务
|
||||
// 在第一个请求就触发清理(被清内容只留「见会话记录」,模型无法取回)。
|
||||
func TestContextWindowScalesFromTokens(t *testing.T) {
|
||||
eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
|
||||
"contextWindow": float64(128000),
|
||||
"outputReserve": float64(16384),
|
||||
"contextBuffer": float64(4096),
|
||||
}}}
|
||||
w, r, b := contextWindow(eff)
|
||||
if w != int(128000*3.35) {
|
||||
t.Fatalf("window = %d, want %d", w, int(128000*3.35))
|
||||
}
|
||||
if r <= 0 || b <= 0 {
|
||||
t.Fatalf("reserve/buffer must be positive chars: %d %d", r, b)
|
||||
}
|
||||
// 旧部署未配置时的默认:不再是 32k 字符。
|
||||
def, _, _ := contextWindow(core.EffectiveSettings{})
|
||||
if def < 100000*3 {
|
||||
t.Fatalf("default window too small: %d", def)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkReplayEnabled(t *testing.T) {
|
||||
eff := func(v string) core.EffectiveSettings {
|
||||
return core.EffectiveSettings{"context": map[string]any{"thinkReplay": v}}
|
||||
}
|
||||
if !thinkReplayEnabled(eff(""), "MiniMax-M2.7-highspeed") {
|
||||
t.Fatal("auto should enable for minimax models")
|
||||
}
|
||||
if thinkReplayEnabled(eff(""), "gpt-5.1") {
|
||||
t.Fatal("auto should stay off for other models")
|
||||
}
|
||||
if !thinkReplayEnabled(eff("on"), "gpt-5.1") {
|
||||
t.Fatal("explicit on must win")
|
||||
}
|
||||
if thinkReplayEnabled(eff("off"), "MiniMax-M2.7-highspeed") {
|
||||
t.Fatal("explicit off must win")
|
||||
}
|
||||
if !strings.Contains("checkpoint", "checkpoint") {
|
||||
t.Fatal("sanity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 修订(同名再登记)后,终轮检查与交付清单只看最新版本;
|
||||
// 无名工件互不合并。
|
||||
func TestLatestDeliverablesKeepsLastPerName(t *testing.T) {
|
||||
st := &directRun{deliverables: []*core.ArtifactRef{
|
||||
{ArtifactID: "art_aaa1", Name: "方案.docx"},
|
||||
{ArtifactID: "art_bbb2", Name: "纪要.docx"},
|
||||
{ArtifactID: "art_ccc3", Name: "方案.docx"},
|
||||
{ArtifactID: "art_ddd4", Name: ""},
|
||||
{ArtifactID: "art_eee5", Name: "tool-run_shell-call_x1.txt", Kind: "data"},
|
||||
}}
|
||||
got := st.latestDeliverables()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 deliverables, got %d: %+v", len(got), got)
|
||||
}
|
||||
for _, ref := range got {
|
||||
if isToolDumpArtifact(ref) {
|
||||
t.Fatalf("tool result dumps are not deliverables: %+v", ref)
|
||||
}
|
||||
}
|
||||
if got[0].ArtifactID != "art_ccc3" || got[0].Name != "方案.docx" {
|
||||
t.Fatalf("方案.docx should resolve to latest: %+v", got[0])
|
||||
}
|
||||
if got[1].ArtifactID != "art_bbb2" {
|
||||
t.Fatalf("纪要.docx should be untouched: %+v", got[1])
|
||||
}
|
||||
if got[2].ArtifactID != "art_ddd4" {
|
||||
t.Fatalf("unnamed artifact must be kept as-is: %+v", got[2])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
|
||||
"agent-system-backend/internal/quality"
|
||||
)
|
||||
|
||||
// P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」——
|
||||
// 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。
|
||||
// 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、
|
||||
// 改成材料里的数值、或明确标注为推测/目标值。人名/称谓不做机械比对
|
||||
// (材料里多为"说话人N",机械比对必然误报)。
|
||||
var (
|
||||
factPctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
|
||||
factDateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日|\d{4}年|\d{4}-\d{1,2}-\d{1,2}`)
|
||||
factBigNumRe = regexp.MustCompile(`\d[\d,]{4,}`)
|
||||
)
|
||||
|
||||
// ExtractFacts 抽取交付物文本里的硬事实 token(去重保序)。
|
||||
func ExtractFacts(text string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
add := func(tok string) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" || seen[tok] {
|
||||
return
|
||||
}
|
||||
seen[tok] = true
|
||||
out = append(out, tok)
|
||||
}
|
||||
for _, m := range factPctRe.FindAllString(text, -1) {
|
||||
add(m)
|
||||
}
|
||||
for _, m := range factDateRe.FindAllString(text, -1) {
|
||||
add(m)
|
||||
}
|
||||
for _, m := range factBigNumRe.FindAllString(text, -1) {
|
||||
add(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UnverifiedFacts 返回材料里找不到出处的 token(白名单:豁免的通用口径词)。
|
||||
func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string {
|
||||
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, tok := range ExtractFacts(artifactText) {
|
||||
if exempt[tok] {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripPunct(s string) string {
|
||||
return strings.NewReplacer(",", "", ",", "", " ", "").Replace(s)
|
||||
}
|
||||
|
||||
// factCheckNotice 把未核实 token 组成终轮反馈(给模型的一轮修正机会)。
|
||||
func factCheckNotice(tokens []string) string {
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("交付物里以下数字/日期在本次材料中找不到出处:")
|
||||
for i, tok := range tokens {
|
||||
if i >= 12 {
|
||||
b.WriteString(" 等")
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
b.WriteString("、")
|
||||
}
|
||||
b.WriteString(tok)
|
||||
}
|
||||
b.WriteString("。逐条二选一:改成材料里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// materialCorpus 汇总本轮可见材料:附件解析文本 + 工具证据文本。
|
||||
// 证据用完整 verifyText(尾部 600 字)而非 160 字摘要——足够定位数字。
|
||||
func (st *directRun) materialCorpus() string {
|
||||
var b strings.Builder
|
||||
for _, doc := range st.rc.Attachments() {
|
||||
b.WriteString(doc.PlainText())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, ev := range st.evidence {
|
||||
b.WriteString(ev.VerifyText)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// deliverableText 拼接本轮交付工件的文本内容(UTF-8 文本直接读;docx
|
||||
// 由 quality 的解析口径承担——这里只收集,不重复解析)。
|
||||
func (st *directRun) deliverableText(refs []*artifactTextPair) string {
|
||||
var b strings.Builder
|
||||
for _, pair := range refs {
|
||||
b.WriteString(pair.text)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type artifactTextPair struct {
|
||||
id string
|
||||
name string
|
||||
text string
|
||||
}
|
||||
|
||||
// collectDeliverableText 经工件端口读取文本类交付物内容
|
||||
// (docx 用 quality 同源的抽取口径;二进制工件跳过)。
|
||||
func (st *directRun) collectDeliverableText(refs []*core.ArtifactRef) []artifactTextPair {
|
||||
lookup := artifactLookupOf(st.rc)
|
||||
var out []artifactTextPair
|
||||
for _, ref := range refs {
|
||||
if ref == nil || ref.ArtifactID == "" {
|
||||
continue
|
||||
}
|
||||
name, content, ok := lookup(string(ref.ArtifactID))
|
||||
if !ok || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
text := deliverablePlainText(name, content)
|
||||
if text != "" {
|
||||
out = append(out, artifactTextPair{id: string(ref.ArtifactID), name: name, text: text})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deliverablePlainText 用 quality 的同源口径抽取工件文本。
|
||||
func deliverablePlainText(name string, content []byte) string {
|
||||
return quality.DeliverableText(name, content)
|
||||
}
|
||||
|
||||
// concatArtifactText 拼接工件文本对。
|
||||
func concatArtifactText(pairs []artifactTextPair) string {
|
||||
var b strings.Builder
|
||||
for _, p := range pairs {
|
||||
b.WriteString(p.text)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// factExemptions 豁免通用口径词(非事实性数字)。
|
||||
func factExemptions() map[string]bool {
|
||||
return map[string]bool{
|
||||
"100%": true, "0%": true, // 常见修辞(100% 确定)与零值
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 事实核查:交付物里材料找不到出处的百分比被抽出并组成反馈;找到的不报。
|
||||
func TestUnverifiedFactsAndNotice(t *testing.T) {
|
||||
material := "目标是准确率 85%以上。截止 10月12日。团队共 12 人。"
|
||||
artifact := "准确率达到 95%,截止 10月20日,覆盖 3,500 用户,目标 85%。"
|
||||
got := UnverifiedFacts(artifact, material, factExemptions())
|
||||
joined := strings.Join(got, ",")
|
||||
for _, want := range []string{"95%", "10月20日", "3,500"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("missing %q in %v", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "85%") {
|
||||
t.Fatalf("sourced figure must not be reported: %v", got)
|
||||
}
|
||||
notice := factCheckNotice(got)
|
||||
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料里") {
|
||||
t.Fatalf("notice malformed: %q", notice)
|
||||
}
|
||||
if UnverifiedFacts("", material, nil) != nil || UnverifiedFacts(artifact, "", nil) != nil {
|
||||
t.Fatal("empty sides short-circuit")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:终轮对带未核实数字的交付物给一轮修正;修正后(重新登记干净版本)放行。
|
||||
func TestFactCheckContinuationOnce(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
verify: true,
|
||||
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
|
||||
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
|
||||
}
|
||||
// 第一轮:登记带编造数字的工件 -> 事实核查续跑一次;第二轮:干净版本通过。
|
||||
turns1 := padTurns(
|
||||
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
|
||||
textDeltaTurn("写好了。"),
|
||||
)
|
||||
turns2 := padTurns(
|
||||
textDeltaTurn("材料里是 85%。"),
|
||||
)
|
||||
r := &LLMRunner{Stream: scriptStream(padTurns(turns1...), nil), VerifyStream: passedStream()}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
sawFact := false
|
||||
for _, p := range rc.assistant {
|
||||
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
|
||||
sawFact = true
|
||||
}
|
||||
}
|
||||
if !sawFact {
|
||||
t.Fatalf("fact check continuation note missing: %+v", rc.assistant)
|
||||
}
|
||||
_ = turns2
|
||||
}
|
||||
+142
-56
@@ -5,16 +5,18 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentmemory "agent-native/runtime/memory"
|
||||
"agent-native/runtime/policy"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/quality"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -67,8 +69,10 @@ const (
|
||||
toolCardCommit = "card_commit"
|
||||
)
|
||||
|
||||
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。
|
||||
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string) {
|
||||
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目
|
||||
// 与本轮命中的 task 层模块(转录权威模式下系统段只在播种时存在,后续轮的新命中
|
||||
// 模块经 Reminders 注入)。
|
||||
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string, []core.PromptBlock) {
|
||||
userText := currentUserText(rc)
|
||||
atts := rc.Attachments()
|
||||
names := make([]string, len(atts))
|
||||
@@ -96,7 +100,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
Text: userText, HasAttachments: len(atts) > 0,
|
||||
}, env, prof.Owner)
|
||||
if err != nil {
|
||||
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}
|
||||
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}, nil
|
||||
}
|
||||
// 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。
|
||||
// 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。
|
||||
@@ -107,10 +111,14 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
}
|
||||
}
|
||||
msgs := make([]agentllm.Message, 0, len(blocks))
|
||||
var obs []string
|
||||
var taskBlocks []core.PromptBlock
|
||||
for _, b := range blocks {
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.Body})
|
||||
if b.Layer == core.LayerTask {
|
||||
taskBlocks = append(taskBlocks, b)
|
||||
}
|
||||
}
|
||||
var obs []string
|
||||
for _, h := range hits {
|
||||
if h.Injected {
|
||||
obs = append(obs, fmt.Sprintf("prompt(%s v%d · %s)", h.ModuleID, versionOf(blocks, h.ModuleID), h.Reason))
|
||||
@@ -118,7 +126,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
obs = append(obs, fmt.Sprintf("skill-yield(%s · %s)", h.ModuleID, h.Reason))
|
||||
}
|
||||
}
|
||||
return msgs, obs
|
||||
return msgs, obs, taskBlocks
|
||||
}
|
||||
|
||||
// deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。
|
||||
@@ -137,6 +145,28 @@ func deliverableStyleLine(eff core.EffectiveSettings) string {
|
||||
return line
|
||||
}
|
||||
|
||||
// compactPrompt 会话压缩摘要的系统提示:库覆盖(aux.session-compact)优先,
|
||||
// 回退九段结构化摘要的内联文案(与提示词库模板同构)。
|
||||
func (l *LLMRunner) compactPrompt(owner string) string {
|
||||
if l.Prompts != nil {
|
||||
if body, ok, err := l.Prompts.ModuleBody(owner, "aux.session-compact"); err == nil && ok && strings.TrimSpace(body) != "" {
|
||||
return body
|
||||
}
|
||||
}
|
||||
return `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 <analysis> 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 <summary> 块。
|
||||
|
||||
摘要必须包含九部分:
|
||||
1. 主要请求和意图:用户要什么、为什么。
|
||||
2. 关键技术概念:领域、技术栈、核心机制。
|
||||
3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。
|
||||
4. 错误和修复:出现过的问题与解法。
|
||||
5. 问题解决过程:尝试过什么、哪些有效哪些放弃。
|
||||
6. 所有用户消息:逐条保留(含澄清与反馈)。
|
||||
7. 待办任务:尚未完成的事项。
|
||||
8. 当前状态:进行到哪一步、最新状态。
|
||||
9. 下一步计划:用户或执行者提出的后续动作。`
|
||||
}
|
||||
|
||||
// toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。
|
||||
func (l *LLMRunner) toolDesc(owner, name, fallback string) string {
|
||||
if l.Prompts != nil {
|
||||
@@ -200,12 +230,20 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return fmt.Errorf("llm: 主模型未配置(当前解析:model=%q base=%q;检查 settings.models.chat 与画像 ModelRoute;演示模式可开 runtime.mock)", route.Model, route.Base)
|
||||
}
|
||||
// 终轮评分的 lint 子进程与 run_python 同一技能根:不注入的话 housestyle
|
||||
// 在后端进程里 import 失败,lint 静默变空结果(评分门禁半边失效)。
|
||||
// 装配放这里而不是 app 组合根:runtime 已依赖 quality(qualityScorer),
|
||||
// app→quality 会被架构棘轮拦截;幂等,进程级只需设一次。
|
||||
if l.SkillsDir != "" {
|
||||
quality.SetLintEnvRoot(l.SkillsDir)
|
||||
}
|
||||
|
||||
stream := l.Stream
|
||||
if stream == nil {
|
||||
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
|
||||
// 此处不再显式包装(防双重)。
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
retries := int(eff.GetNumber("runtime.llmRetries", 2))
|
||||
stream = agentllm.WithRetry(client.Stream, retries, nil)
|
||||
stream = client.Stream
|
||||
}
|
||||
|
||||
// 知识注入:当前消息包含 @knowledge 引用时检索注入(最小实现:显式引用)。
|
||||
@@ -222,15 +260,18 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
currentUser, _ := lastAddressed(hist)
|
||||
var sys []agentllm.Message
|
||||
var promptObs []string
|
||||
var taskBlocks []core.PromptBlock
|
||||
if l.Prompts != nil {
|
||||
sys, promptObs = l.resolveSystem(rc, route.Model)
|
||||
sys, promptObs, taskBlocks = l.resolveSystem(rc, route.Model)
|
||||
}
|
||||
// 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。
|
||||
resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) }
|
||||
if ms, ok := rc.(interface{ Memories() []core.MemoryEntry }); ok {
|
||||
sys = ctxasm.InjectMemory(sys, ms.Memories())
|
||||
}
|
||||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
|
||||
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
|
||||
// 组装侧不再注入(防双份;注入文案同一形态)。
|
||||
_ = ctxasm.InjectMemory
|
||||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
|
||||
ThinkReplay: thinkReplayEnabled(eff, route.Model),
|
||||
})
|
||||
tail := ctxasm.Tail{
|
||||
Instructions: eff.GetString("context.userInstructions", ""),
|
||||
History: hist,
|
||||
@@ -316,7 +357,13 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"),
|
||||
MessageShape: eff.GetString("context.messageShape", "merge"),
|
||||
}
|
||||
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
|
||||
// 转录权威模式的逐轮刷新(经 Reminders 注入,不落转录)。
|
||||
refresh := turnRefresh{
|
||||
Notes: ctxasm.TailNotes(tail),
|
||||
EnvUpdate: envUpdateLine(rc, route.Model),
|
||||
TaskBlocks: taskBlocks,
|
||||
}
|
||||
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools, refresh)
|
||||
}
|
||||
|
||||
func permissionModeOf(rc RunCtx) string {
|
||||
@@ -359,13 +406,33 @@ func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolD
|
||||
return next
|
||||
}
|
||||
|
||||
// contextWindow 把模型真实窗口(token)换算成内核清理阈值用的字符数。
|
||||
// 内核 EstimateChars 按 rune 计数;按 ctxasm 同口径(UTF-8 字节/3.35 ≈ token,
|
||||
// 中文约一字一 token)反推 chars ≈ tokens×3.35。此前 32000 字符的硬默认会让
|
||||
// 带长材料的任务在第一个请求就触发清理,被清内容只留一句「见会话记录」。
|
||||
func contextWindow(eff core.EffectiveSettings) (window, reserve, buffer int) {
|
||||
window = int(eff.GetNumber("models.chat.contextWindow", 32000))
|
||||
reserve = int(eff.GetNumber("models.chat.outputReserve", 4096))
|
||||
buffer = int(eff.GetNumber("models.chat.contextBuffer", 1024))
|
||||
tokens := eff.GetNumber("models.chat.contextWindow", 128000)
|
||||
if tokens < 4096 {
|
||||
tokens = 4096
|
||||
}
|
||||
window = int(tokens * 3.35)
|
||||
reserve = int(eff.GetNumber("models.chat.outputReserve", 16384) * 3.35)
|
||||
buffer = int(eff.GetNumber("models.chat.contextBuffer", 4096) * 3.35)
|
||||
return
|
||||
}
|
||||
|
||||
// thinkReplayEnabled 决定历史 assistant 消息是否原样回传 <think> 块。
|
||||
// MiniMax 官方要求保留(去掉会掉性能);auto 按模型名识别。
|
||||
func thinkReplayEnabled(eff core.EffectiveSettings, model string) bool {
|
||||
switch eff.GetString("context.thinkReplay", "auto") {
|
||||
case "on":
|
||||
return true
|
||||
case "off":
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(model), "minimax")
|
||||
}
|
||||
|
||||
// loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制;
|
||||
// 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。
|
||||
func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) {
|
||||
@@ -398,6 +465,28 @@ func outputTokenCap(budget core.RunBudget, eff core.EffectiveSettings, key strin
|
||||
return 0
|
||||
}
|
||||
|
||||
// memoryStore 是记忆端口的最小面(agent-native runtime/memory.Store 的本地别名;
|
||||
// 组合根经 runCtx 注入,runmgr 的 storeMemory 实现之)。
|
||||
type memoryStore = agentmemory.Store
|
||||
|
||||
// retryAttemptsOf 把 runtime.llmRetries 的设置语义(0=不重试,N=N 次)
|
||||
// 映射到库 Config.RetryAttempts(0=默认 2,负值=关,正数=次数)。
|
||||
func retryAttemptsOf(eff core.EffectiveSettings) int {
|
||||
n := int(eff.GetNumber("runtime.llmRetries", 2))
|
||||
if n <= 0 {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// agentNameOf 是 OTel gen_ai.agent.name 的取值(画像 ID,空回退 agent-system)。
|
||||
func agentNameOf(prof core.AgentProfile) string {
|
||||
if strings.TrimSpace(prof.ID) != "" {
|
||||
return prof.ID
|
||||
}
|
||||
return "agent-system"
|
||||
}
|
||||
|
||||
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
|
||||
func openLLMClient(base, key string) *agentllm.Client {
|
||||
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
|
||||
@@ -453,61 +542,58 @@ func orDefault(s, def string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
const maxDeliverableGuards = 2
|
||||
|
||||
const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。正文里的「调用工具」「工具结果」「产出工件」字样不会执行任何东西,调用工具必须用函数调用。"
|
||||
|
||||
const discardedClaimNote = "(上一段回复声称已交付,但本轮没有任何工具调用,已作废)"
|
||||
|
||||
const deliverableFallbackNotice = "本轮未实际生成文件(模型声称已生成,但没有调用工具)。请重试,或换一种说法再试一次。"
|
||||
|
||||
var (
|
||||
artIDRe = regexp.MustCompile(`art_[0-9a-f]{12}`)
|
||||
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
|
||||
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
|
||||
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
|
||||
)
|
||||
|
||||
// claimsFalseDeliverable 保留给小队入口:命中即判失败,不走直连路径的纠正轮次。
|
||||
func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool {
|
||||
block, _ := deliverableGate(body, produced, rc)
|
||||
block, _ := deliverableGateOf(body, produced, rc)
|
||||
return block
|
||||
}
|
||||
|
||||
// deliverableGate 判断终轮正文是否在没有对应工件的情况下声称交付。
|
||||
// 返回值的形状与 Stop 门禁一致,P1 平移进内核时不用再改调用方。
|
||||
// 引用本轮已产出、或项目里确实存在的工件,不算谎称;正文里的伪造调用标记始终拦截。
|
||||
func deliverableGate(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return false, ""
|
||||
}
|
||||
if forgedCallRe.MatchString(body) {
|
||||
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
|
||||
}
|
||||
producedIDs := map[string]bool{}
|
||||
for _, ref := range produced {
|
||||
if ref != nil && ref.ArtifactID != "" {
|
||||
producedIDs[ref.ArtifactID] = true
|
||||
}
|
||||
}
|
||||
knownExisting := false
|
||||
for _, id := range artIDRe.FindAllString(body, -1) {
|
||||
if producedIDs[id] {
|
||||
// deliverableGateOf 把产品工件存取投影成库端口后调用下沉的 ClaimGate(N1 WP5)。
|
||||
func deliverableGateOf(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
|
||||
return gates.ClaimGate(body, artifactRefsOf(produced), artifactLookupOf(rc))
|
||||
}
|
||||
|
||||
// artifactRefsOf 把 core.ArtifactRef 投影为库的最小形态。
|
||||
func artifactRefsOf(refs []*core.ArtifactRef) []gates.ArtifactRef {
|
||||
out := make([]gates.ArtifactRef, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if ref == nil || ref.ArtifactID == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, ok := rc.Artifact(id); ok {
|
||||
knownExisting = true
|
||||
continue
|
||||
out = append(out, gates.ArtifactRef{ID: ref.ArtifactID, Name: ref.Name, Kind: ref.Kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// artifactLookupOf 把 rc.Artifact 投影为库的工件存取端口。
|
||||
func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
|
||||
return func(id string) (string, []byte, bool) {
|
||||
art, content, ok := rc.Artifact(id)
|
||||
if !ok {
|
||||
return "", nil, false
|
||||
}
|
||||
return true, "回复引用了不存在的工件"
|
||||
return art.Name, content, true
|
||||
}
|
||||
if len(producedIDs) > 0 || knownExisting {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5):
|
||||
// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度
|
||||
// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径——
|
||||
// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。
|
||||
func hardFactScorer() gates.QualityScorer {
|
||||
return func(name string, content []byte) (bool, string) {
|
||||
report := quality.HardFacts(name, content)
|
||||
if report.Passed {
|
||||
return true, ""
|
||||
}
|
||||
return false, strings.Join(report.Violations, ";")
|
||||
}
|
||||
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
|
||||
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func summarize(out string, err error) string {
|
||||
|
||||
+253
-47
@@ -4,17 +4,23 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
"agent-native/runtime/checkpoint"
|
||||
"agent-native/runtime/filestate"
|
||||
"agent-native/runtime/gates"
|
||||
"agent-native/runtime/hooks"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-native/runtime/reminders"
|
||||
agentsession "agent-native/runtime/session"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/wfcompile"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -26,6 +32,7 @@ type directRun struct {
|
||||
rc RunCtx
|
||||
eff core.EffectiveSettings
|
||||
cardflow bool
|
||||
memoryStore memoryStore
|
||||
finalParts []core.Part
|
||||
explicitCard bool
|
||||
deliverables []*core.ArtifactRef
|
||||
@@ -45,18 +52,19 @@ type directRun struct {
|
||||
inflight *toolSlot
|
||||
turnBody string
|
||||
|
||||
evidence []toolEvidence
|
||||
todos core.TodoList
|
||||
plan core.PlanSnapshot
|
||||
closedToolTurn bool
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
checklistReminders int
|
||||
checks []core.CheckPart
|
||||
notes *reminders.Bus
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
evidence []toolEvidence
|
||||
todos core.TodoList
|
||||
plan core.PlanSnapshot
|
||||
closedToolTurn bool
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
nudgedPlanless bool
|
||||
factChecked bool
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
}
|
||||
|
||||
type toolEvidence struct {
|
||||
@@ -88,8 +96,12 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
|
||||
return t.run(ctx, args), nil
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int) error {
|
||||
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
|
||||
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
|
||||
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
|
||||
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
|
||||
st.memoryStore = ms.StoreMemory()
|
||||
}
|
||||
st.loadChecklist()
|
||||
if msg, ok := lastAddressed(rc.History()); ok && msg.Role == core.RoleSystem {
|
||||
var text string
|
||||
@@ -118,12 +130,39 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
return st.runTool(name, string(args))
|
||||
}})
|
||||
}
|
||||
// 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、
|
||||
// 精确 edit/multi_edit(先读后写)、grep/glob/list——沙箱=会话工作区。
|
||||
// 产品侧同域 fs_* 仍按 runtime.fsTools 门控(隐藏),不与内核名冲突。
|
||||
if sandbox := conversationSandbox(rc); sandbox != "" {
|
||||
var cpLog *checkpoint.Log
|
||||
var anchor func() string
|
||||
if host, ok := rc.(interface {
|
||||
OpenCheckpoints() (*checkpoint.Log, error)
|
||||
CheckpointAnchor() string
|
||||
}); ok {
|
||||
if lg, err := host.OpenCheckpoints(); err == nil {
|
||||
cpLog = lg
|
||||
a := host.CheckpointAnchor()
|
||||
anchor = func() string { return a }
|
||||
}
|
||||
}
|
||||
ws := agenttools.WorkspaceToolsCheckpointed(sandbox, filestate.New(), cpLog, anchor)
|
||||
for _, wd := range ws.Decls() {
|
||||
name := wd.Name
|
||||
decls = append(decls, agentllm.ToolDecl{Name: wd.Name, Description: wd.Description, Parameters: wd.Parameters})
|
||||
wsReg := ws
|
||||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||||
res := wsReg.Execute(ctx, name, args)
|
||||
st.noteKernelResult(res)
|
||||
return res
|
||||
}})
|
||||
}
|
||||
decls = filterPlanDecls(decls, permissionModeOf(rc))
|
||||
}
|
||||
|
||||
var history []agentllm.Message
|
||||
user := agentllm.Message{Role: agentllm.RoleUser}
|
||||
if len(msgs) > 0 {
|
||||
user = msgs[len(msgs)-1]
|
||||
history = append(history, msgs[:len(msgs)-1]...)
|
||||
}
|
||||
wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
if streamOpts.MaxTokens > 0 {
|
||||
@@ -159,6 +198,62 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
}
|
||||
|
||||
window, reserve, buffer := contextWindow(st.eff)
|
||||
// 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)——
|
||||
// 此前占位只写「见会话记录」,而模型没有读会话记录的工具。
|
||||
var resultsDir string
|
||||
sandbox := conversationSandbox(rc)
|
||||
if sandbox != "" {
|
||||
resultsDir = filepath.Join(sandbox, ".agent", "results")
|
||||
}
|
||||
// 会话转录为权威上下文(质量 P2-I2):内核 Session 承载跨轮历史——
|
||||
// 完整工具结果/参数/附件原文随转录持久,不再由界面消息按 160 字摘要
|
||||
// 重建。首轮(或重播种)用 ctxasm 整段组装做一次性种子;此后每轮只传
|
||||
// 当前用户消息,系统段与动态环境经 Reminders 刷新。
|
||||
// 结构分歧(排除消息 / 变体切换 / 重新生成)改变指纹 ⇒ 重播种转录,
|
||||
// 保证转录与消息库一致(regenerate/exclude/候选选择的等价映射)。
|
||||
// 打开失败降级为无 Session(旧行为:ctxasm 整段组装,不阻断对话)。
|
||||
var sessStore *agentsession.Store
|
||||
seedRun := true
|
||||
if sandbox != "" {
|
||||
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
|
||||
rev := contextRevision(rc.History())
|
||||
if opened, oerr := agentsession.Open(transcriptPath); oerr == nil {
|
||||
meta := readSeedMeta(transcriptPath)
|
||||
switch {
|
||||
case opened.Stats().Lines == 0:
|
||||
// 首次播种:本轮回整段组装作为一次性种子并登记指纹。
|
||||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||||
case meta != nil && meta.Revision != rev:
|
||||
// 结构分歧(排除/变体切换/重新生成):丢弃旧转录,按当前消息库重建。
|
||||
if rerr := resetTranscript(transcriptPath); rerr == nil {
|
||||
if reopened, rerr2 := agentsession.Open(transcriptPath); rerr2 == nil {
|
||||
opened = reopened
|
||||
}
|
||||
}
|
||||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||||
log.Printf("[runtime] transcript reseed: rev=%s", rev)
|
||||
default:
|
||||
// 正常续跑;或无指纹的既有转录(JournalOnly 时代/崩溃现场):
|
||||
// 采纳为权威,补登记指纹,不破坏崩溃配对修复。
|
||||
seedRun = false
|
||||
if meta == nil {
|
||||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||||
}
|
||||
}
|
||||
sessStore = opened
|
||||
} else {
|
||||
log.Printf("[runtime] open session transcript: %v", oerr)
|
||||
}
|
||||
}
|
||||
var history []agentllm.Message
|
||||
if sessStore == nil || seedRun {
|
||||
// 无转录权威:整段组装(旧行为);或播种轮:完整历史作一次性种子。
|
||||
history = append(history, msgs[:len(msgs)-1]...)
|
||||
}
|
||||
// 转录接近窗口时先做结构化压缩(9 段模板),内核请求侧的微清理兜底。
|
||||
if sessStore != nil && !seedRun {
|
||||
st.maybeCompact(sessStore, stream, model, streamOpts, window, reserve, buffer)
|
||||
}
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.Stop {
|
||||
@@ -173,6 +268,8 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
res, err := nruntime.Run(rc.Context(), nruntime.Config{
|
||||
Model: model,
|
||||
History: history,
|
||||
Session: sessStore,
|
||||
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
|
||||
Tools: reg,
|
||||
Stream: wrapped,
|
||||
MaxTurns: maxTurns,
|
||||
@@ -183,25 +280,63 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
ContextWindow: window,
|
||||
OutputReserve: reserve,
|
||||
ContextBuffer: buffer,
|
||||
ResultsDir: resultsDir,
|
||||
OnEvent: st.onEvent,
|
||||
Hooks: bus,
|
||||
Reminders: st.reminders,
|
||||
Trace: l.Trace,
|
||||
Reminders: func(turn int) []string {
|
||||
var out []string
|
||||
out = append(out, st.reminders(turn)...)
|
||||
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
|
||||
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
|
||||
if sessStore != nil && !seedRun && turn == 0 {
|
||||
out = append(out, refresh.Notes...)
|
||||
if refresh.EnvUpdate != "" {
|
||||
out = append(out, refresh.EnvUpdate)
|
||||
}
|
||||
if sandbox != "" {
|
||||
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
|
||||
var ids []string
|
||||
for _, b := range refresh.TaskBlocks {
|
||||
ids = append(ids, b.ModuleID)
|
||||
}
|
||||
for _, id := range updateRemindedModules(transcriptPath, ids) {
|
||||
for _, b := range refresh.TaskBlocks {
|
||||
if b.ModuleID == id {
|
||||
out = append(out, "本轮适用的补充指引("+b.ModuleID+"):\n"+b.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
PermissionAsk: PermissionAskOf(rc),
|
||||
Trace: l.Trace,
|
||||
AgentName: agentNameOf(rc.Profile()),
|
||||
Memory: st.memoryStore,
|
||||
RetryAttempts: retryAttemptsOf(eff),
|
||||
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
|
||||
return st.runTool(call.Name, call.Arguments)
|
||||
},
|
||||
}, user)
|
||||
composed := false
|
||||
if err == nil && res.Err == nil && len(st.evidence) > 0 && st.eff.GetBool("runtime.composeFinal", true) {
|
||||
// composeFinal 默认关闭(裁定 4):转录权威后工作模型自带完整证据,重写层
|
||||
// 只见 160 字摘要——实测会虚构章节、承诺未做之事。开启或异常兜底时才重写。
|
||||
composeWanted := st.eff.GetBool("runtime.composeFinal", false)
|
||||
if err == nil && res.Err == nil && len(st.evidence) > 0 && composeWanted {
|
||||
composed = st.composeFinal(stream, model, res.Notice, streamOpts)
|
||||
}
|
||||
if err == nil && res.Err == nil && !composed && res.Notice == "" && strings.TrimSpace(st.turnBody) == "" && len(st.evidence) > 0 {
|
||||
// 兜底:工作轮结束但没有任何给用户的正文(罕见),才用重写层补一段。
|
||||
composed = st.composeFinal(stream, model, "", streamOpts)
|
||||
}
|
||||
if res.Notice != "" && !composed {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: res.Notice})
|
||||
}
|
||||
if err == nil && res.Err == nil && len(st.finalParts) == 0 {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
|
||||
}
|
||||
for _, ref := range st.deliverables {
|
||||
for _, ref := range st.latestDeliverables() {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||||
}
|
||||
st.switchMu.Lock()
|
||||
@@ -230,6 +365,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
|
||||
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
|
||||
func (st *directRun) noteKernelResult(res agenttools.Result) {
|
||||
if st.inflight == nil {
|
||||
return
|
||||
}
|
||||
summary := summarize(res.Content, nil)
|
||||
if res.IsError {
|
||||
summary = "error: " + summary
|
||||
}
|
||||
st.inflight.summary = summary
|
||||
st.inflight.status = statusOf(nil)
|
||||
if res.IsError {
|
||||
st.inflight.status = "error"
|
||||
}
|
||||
st.inflight.verifyText = tailRunes(res.Content, 600)
|
||||
}
|
||||
|
||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||
// 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。
|
||||
func (st *directRun) noteModelSwitch(from, to string) {
|
||||
@@ -251,6 +404,9 @@ func (st *directRun) noteToolDelta(ev agentllm.Event) {
|
||||
|
||||
func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
switch ev.Type {
|
||||
case "auto_retry_start":
|
||||
// 重试可观测(N3 WP6):沿用 process part 形态。
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "模型在输出前失败,正在重试(" + ev.Text + ")", Process: true})
|
||||
case "turn_start":
|
||||
st.responding = false
|
||||
st.acting = false
|
||||
@@ -290,6 +446,11 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
st.usageTotal.PromptTokens += ev.PromptTokens
|
||||
st.usageTotal.CompletionTokens += ev.CompletionTokens
|
||||
st.usageTotal.TotalTokens += ev.TotalTokens
|
||||
// 计费拆分(N3 WP6):上一请求覆盖、缓存命中累计。
|
||||
st.usageTotal.LastPromptTokens = ev.PromptTokens
|
||||
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
|
||||
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
|
||||
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
|
||||
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
|
||||
hasCalls := len(ev.Message.ToolCalls) > 0
|
||||
if st.turnThink.Len() > 0 {
|
||||
@@ -347,12 +508,12 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
}
|
||||
|
||||
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
|
||||
if block, why := deliverableGate(st.turnBody, st.deliverables, st.rc); block {
|
||||
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
|
||||
st.demoteLastText()
|
||||
if st.eff.GetBool("runtime.verify", false) {
|
||||
st.addVerifyNote(why)
|
||||
}
|
||||
if info.Continuations < maxDeliverableGuards {
|
||||
if info.Continuations < gates.MaxDeliverableGuards {
|
||||
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: systemCheckReason(deliverableGuardNote)}
|
||||
}
|
||||
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
|
||||
@@ -360,10 +521,28 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
|
||||
if !st.eff.GetBool("runtime.verify", false) {
|
||||
return nruntime.StopDecision{}
|
||||
}
|
||||
if reasons := checkDeliverables(st.rc, st.deliverables); len(reasons) > 0 {
|
||||
// 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本
|
||||
// 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。
|
||||
latest := st.latestDeliverables()
|
||||
// N1 WP5:工件存在/非空/硬事实检查走下沉的 CheckArtifacts(端口在 llm.go 投影;
|
||||
// P5 后 scorer 只拦 lint error 与 lint 未运行)。
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 {
|
||||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
|
||||
// P5:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料;
|
||||
// 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的
|
||||
// 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。
|
||||
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations && !st.factChecked {
|
||||
pairs := st.collectDeliverableText(latest)
|
||||
if len(pairs) > 0 {
|
||||
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
|
||||
st.factChecked = true
|
||||
st.addVerifyNote("事实核查:" + factCheckNotice(tokens))
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))}
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
|
||||
if err != nil {
|
||||
st.addVerifyNote(err.Error())
|
||||
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
|
||||
@@ -393,6 +572,37 @@ func (st *directRun) demoteLastText() int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// latestDeliverables 同名工件只保留最后登记的版本,顺序不变。
|
||||
// 修订(同名再登记)后旧版本不再是交付物:不进终轮检查、不重复出现在
|
||||
// 回复的交付清单里。无名工件互不合并。
|
||||
// 超长工具输出的截断转存(runmgr 的 tool-<调用>.txt,kind=data)不是交付物:
|
||||
// 不进终轮检查、不占交付卡片(实测被评分器当 .txt 交付物误伤,检查连判负)。
|
||||
func (st *directRun) latestDeliverables() []*core.ArtifactRef {
|
||||
idx := map[string]int{}
|
||||
var out []*core.ArtifactRef
|
||||
for _, ref := range st.deliverables {
|
||||
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
||||
out = append(out, ref)
|
||||
continue
|
||||
}
|
||||
if isToolDumpArtifact(ref) {
|
||||
continue
|
||||
}
|
||||
if i, ok := idx[ref.Name]; ok {
|
||||
out[i] = ref
|
||||
continue
|
||||
}
|
||||
idx[ref.Name] = len(out)
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isToolDumpArtifact 识别「工具结果超长截断转工件」的落盘(上下文救济,非交付物)。
|
||||
func isToolDumpArtifact(ref *core.ArtifactRef) bool {
|
||||
return ref.Kind == "data" && strings.HasPrefix(ref.Name, "tool-")
|
||||
}
|
||||
|
||||
func (st *directRun) addVerifyNote(reason string) {
|
||||
st.addCheck(false, reason)
|
||||
}
|
||||
@@ -424,7 +634,7 @@ func (st *directRun) addCheck(passed bool, reason string) {
|
||||
}
|
||||
|
||||
func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision {
|
||||
if info.Continuations < maxVerifyContinuations {
|
||||
if info.Continuations < gates.MaxVerifyContinuations {
|
||||
st.demoteLastText()
|
||||
st.addVerifyNote(reason)
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
|
||||
@@ -553,8 +763,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
st.inflight.status = status
|
||||
st.inflight.verifyText = tailRunes(out, 600)
|
||||
}
|
||||
if len(out) > 16000 {
|
||||
out = out[:16000] + "…(超长截断)"
|
||||
if r := []rune(out); len(r) > 16000 {
|
||||
out = string(r[:16000]) + "…(超长截断)"
|
||||
}
|
||||
return agenttools.Result{Content: out, IsError: err != nil}
|
||||
}
|
||||
@@ -594,7 +804,9 @@ func (st *directRun) runRegisteredWorkflow(args string) string {
|
||||
if !ok {
|
||||
return "未注册的工作流 " + a.Workflow
|
||||
}
|
||||
res, err := (WorkflowRunner{}).run(st.rc, registeredWorkflowLaunch(st.rc, def, a.Inputs))
|
||||
// Q3 WP2:注册工作流经编译图执行(P4 上限在编译入口强制)。
|
||||
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc,
|
||||
registeredWorkflowLaunch(st.rc, def, a.Inputs), wfcompile.RunOptions{})
|
||||
st.pinSubagentArtifacts(res.Summary)
|
||||
if err != nil {
|
||||
if res.Summary != "" {
|
||||
@@ -606,31 +818,25 @@ func (st *directRun) runRegisteredWorkflow(args string) string {
|
||||
}
|
||||
|
||||
func (st *directRun) runScriptWorkflow(body string) string {
|
||||
text, arts, err := runModelScript(st.rc, WorkflowRunner{}, body)
|
||||
var ids []string
|
||||
for _, art := range arts {
|
||||
if art != nil {
|
||||
ids = append(ids, string(art.ArtifactID))
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += "工件:" + strings.Join(ids, "、")
|
||||
}
|
||||
st.pinSubagentArtifacts(text)
|
||||
// Q3 WP2:模型脚本包成单步工作流走编译图——runtime.workflowScripts
|
||||
// 开关与执行前确认由 wfcompile 宿主处理器持有(P4/legacy 同款:模型脚本
|
||||
// 只有脚本级确认门,不叠加计划确认)。
|
||||
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc, modelScriptLaunch(body),
|
||||
wfcompile.RunOptions{ModelScript: true})
|
||||
st.pinSubagentArtifacts(res.Summary)
|
||||
if err != nil {
|
||||
if text != "" {
|
||||
return text + "\n" + err.Error()
|
||||
if res.Summary != "" {
|
||||
return res.Summary + "\n" + err.Error()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
return text
|
||||
return res.Summary
|
||||
}
|
||||
|
||||
func (st *directRun) runPlannedWorkflow(goal string) string {
|
||||
res, err := planWorkflow(st.rc, goal, WorkflowRunner{})
|
||||
// Q3 WP2:规划分支——规划子代理产出 WorkflowDef,动态上限编译,
|
||||
// 执行前强制计划确认(legacy planWorkflow 同款流)。
|
||||
res, err := planCompiledWorkflow(st.rc, goal, CompiledWorkflowRunner{})
|
||||
st.pinSubagentArtifacts(res.Summary)
|
||||
if err != nil {
|
||||
if res.Summary != "" {
|
||||
@@ -727,7 +933,7 @@ func (st *directRun) pinSubagentArtifacts(text string) {
|
||||
seen[ref.ArtifactID] = true
|
||||
}
|
||||
}
|
||||
for _, id := range artIDRe.FindAllString(text, -1) {
|
||||
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentsession "agent-native/runtime/session"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- N3 WP2:产品会话路径持久化(验收① 产品层机器证据)----
|
||||
|
||||
func textTurns(texts ...string) [][]agentllm.Event {
|
||||
out := make([][]agentllm.Event, 0, len(texts))
|
||||
for _, t := range texts {
|
||||
out = append(out, []agentllm.Event{
|
||||
{Kind: agentllm.KindTextDelta, Text: t},
|
||||
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: t, StopReason: agentllm.StopEnd}},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 杀窗口形态的种子转录:新 run 前配对修复成立,且 run 正常产出。
|
||||
func TestDirectRunPairsAfterKillSeed(t *testing.T) {
|
||||
sandbox := t.TempDir()
|
||||
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "sys"},
|
||||
{Role: agentllm.RoleUser, Content: "早前"},
|
||||
{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{{ID: "c1", Name: "run_python", Arguments: `{}`}}},
|
||||
} {
|
||||
if err := sess.Append(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(), sandbox: sandbox,
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
history: []core.Message{
|
||||
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "还在吗"}}},
|
||||
},
|
||||
}
|
||||
r := &LLMRunner{Stream: scriptStream(textTurns("还在"), nil)}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
loaded, err := sess.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var paired bool
|
||||
for _, m := range loaded {
|
||||
if m.Role == agentllm.RoleTool && m.ToolCallID == "c1" {
|
||||
paired = true
|
||||
if !m.IsError || !strings.Contains(m.Content, "进程中断") {
|
||||
t.Fatalf("兜底配对形态不对:%+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !paired {
|
||||
t.Fatalf("杀窗口调用未配对:%#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// 转录权威语义(质量 P2-I2):首次播种把组装历史持久进转录且恰好一次;
|
||||
// 后续 run 由转录承载,不重复播种。
|
||||
func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
|
||||
sandbox := t.TempDir()
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(), sandbox: sandbox,
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
history: []core.Message{
|
||||
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "历史一"}}},
|
||||
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "回复一"}}},
|
||||
{ID: "m3", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "最新"}}},
|
||||
},
|
||||
}
|
||||
var requests [][]agentllm.Message
|
||||
base := scriptStream(textTurns("一答", "二答"), nil)
|
||||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
requests = append(requests, append([]agentllm.Message{}, msgs...))
|
||||
return base(ctx, model, msgs, tools, opts)
|
||||
}}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("first Execute: %v", err)
|
||||
}
|
||||
// 第二跑:同一 rc(新 user 消息由 history 尾部提供)。
|
||||
rc.history = append(rc.history, core.Message{ID: "m4", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "再来"}}})
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("second Execute: %v", err)
|
||||
}
|
||||
if len(requests) < 2 {
|
||||
t.Fatalf("stream 调用数 = %d", len(requests))
|
||||
}
|
||||
for i, req := range requests {
|
||||
count := 0
|
||||
for _, m := range req {
|
||||
if m.Content == "历史一" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("第 %d 跑历史中「历史一」必须恰好一次,得到 %d", i+1, count)
|
||||
}
|
||||
}
|
||||
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := sess.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for _, m := range loaded {
|
||||
if m.Content == "历史一" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("转录应恰好一次包含种子历史「历史一」,得到 %d:%#v", count, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)----
|
||||
|
||||
// 端口注入的 [记忆] 块到达请求(迁移前经组装侧 InjectMemory 同文案形态)。
|
||||
func TestDirectRunInjectsMemoryBlock(t *testing.T) {
|
||||
var gotReq []agentllm.Message
|
||||
base := scriptStream(textTurns("收到"), nil)
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
memEntries: []memEntry{
|
||||
{typ: "fact", content: "项目用 Go 1.27"},
|
||||
{typ: "preference", content: "喜欢简洁回复"},
|
||||
},
|
||||
}
|
||||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
gotReq = append([]agentllm.Message{}, msgs...)
|
||||
return base(ctx, model, msgs, tools, opts)
|
||||
}}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
var block string
|
||||
for _, m := range gotReq {
|
||||
if strings.Contains(m.Content, "[记忆]") {
|
||||
block = m.Content
|
||||
}
|
||||
}
|
||||
if block == "" {
|
||||
t.Fatalf("记忆块缺失:%#v", gotReq)
|
||||
}
|
||||
// 形态对照(与 ctxasm.InjectMemory 同文):[记忆]\n- (typ) content,确定性排序。
|
||||
if !strings.Contains(block, "- (fact) 项目用 Go 1.27") ||
|
||||
!strings.Contains(block, "- (preference) 喜欢简洁回复") {
|
||||
t.Fatalf("记忆块形态不对:%q", block)
|
||||
}
|
||||
if strings.Index(block, "(fact)") > strings.Index(block, "(preference)") {
|
||||
t.Fatalf("排序不确定:%q", block)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
agentmemory "agent-native/runtime/memory"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -39,6 +41,9 @@ type fakeRunCtx struct {
|
||||
history []core.Message // 非 nil 时覆盖默认历史
|
||||
bgID string
|
||||
bgErr error
|
||||
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
|
||||
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
|
||||
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
|
||||
bgCalls int
|
||||
}
|
||||
|
||||
@@ -58,6 +63,15 @@ func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
|
||||
func (f *fakeRunCtx) AddUsage(u core.Usage) {
|
||||
f.usageTotal.PromptTokens += u.PromptTokens
|
||||
f.usageTotal.CompletionTokens += u.CompletionTokens
|
||||
f.usageTotal.TotalTokens += u.TotalTokens
|
||||
f.usageTotal.LastPromptTokens = u.LastPromptTokens
|
||||
f.usageTotal.LastCompletionTokens = u.LastCompletionTokens
|
||||
f.usageTotal.CacheReadTokens += u.CacheReadTokens
|
||||
f.usageTotal.CacheWriteTokens += u.CacheWriteTokens
|
||||
}
|
||||
func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
return core.HITLResponse{}, fmt.Errorf("not implemented")
|
||||
}
|
||||
@@ -94,6 +108,34 @@ func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID,
|
||||
}
|
||||
return f.bgID, nil
|
||||
}
|
||||
func (f *fakeRunCtx) ConversationSandbox() string {
|
||||
if f.sandbox != "" {
|
||||
return f.sandbox
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// memEntry 是测试用记忆条目(避免 fake 引入库类型)。
|
||||
type memEntry struct{ typ, content string }
|
||||
|
||||
type fakeMemStore struct{ entries []memEntry }
|
||||
|
||||
func (s fakeMemStore) Recall(_ context.Context, q agentmemory.RecallQuery) ([]agentmemory.Entry, error) {
|
||||
out := make([]agentmemory.Entry, 0, len(s.entries))
|
||||
for _, e := range s.entries {
|
||||
out = append(out, agentmemory.Entry{Type: e.typ, Content: e.content})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s fakeMemStore) Append(_ context.Context, e agentmemory.Entry) error { return nil }
|
||||
|
||||
func (f *fakeRunCtx) StoreMemory() agentmemory.Store {
|
||||
if f.memEntries == nil {
|
||||
return nil
|
||||
}
|
||||
return fakeMemStore{entries: f.memEntries}
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
|
||||
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
|
||||
func (f *fakeRunCtx) Subtask() string { return "" }
|
||||
@@ -784,7 +826,8 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
|
||||
if calls != 2 {
|
||||
t.Fatalf("stream calls = %d, want 2", calls)
|
||||
}
|
||||
var placeholder bool
|
||||
// N6(N3 WP1):伪造正文不得作为 assistant 的话术回放;更正以 user
|
||||
// 消息出现(不再改写 assistant)。
|
||||
for _, m := range replay {
|
||||
if m.Role != agentllm.RoleAssistant {
|
||||
continue
|
||||
@@ -792,12 +835,15 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
|
||||
if strings.Contains(m.Content, "run_python") {
|
||||
t.Fatalf("forged body was replayed: %q", m.Content)
|
||||
}
|
||||
if strings.Contains(m.Content, "已作废") {
|
||||
placeholder = true
|
||||
}
|
||||
var correction bool
|
||||
for _, m := range replay {
|
||||
if m.Role == agentllm.RoleUser && strings.Contains(m.Content, "已作废") {
|
||||
correction = true
|
||||
}
|
||||
}
|
||||
if !placeholder {
|
||||
t.Fatalf("correction replay missing placeholder, got %#v", replay)
|
||||
if !correction {
|
||||
t.Fatalf("correction replay missing user note, got %#v", replay)
|
||||
}
|
||||
texts := textParts(rc.assistant)
|
||||
if len(texts) != 2 || !texts[0].Process || texts[1].Process {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -218,11 +219,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
|
||||
if p.Text == "答案是 42。" && !p.Process {
|
||||
kept = true
|
||||
}
|
||||
if !p.Process && strings.Contains(p.Text, "验证器不可用") {
|
||||
if !p.Process && strings.Contains(p.Text, "验证器未独立配置") {
|
||||
notice = true
|
||||
}
|
||||
}
|
||||
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
|
||||
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") {
|
||||
t.Fatalf("parts = %+v", texts)
|
||||
}
|
||||
})
|
||||
@@ -230,11 +231,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseVerdictToleratesFence(t *testing.T) {
|
||||
got, err := parseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
|
||||
got, err := gates.ParseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
|
||||
if err != nil || got.Passed || got.Reason != "不够" {
|
||||
t.Fatalf("verdict = %+v %v", got, err)
|
||||
}
|
||||
if _, err := parseVerdict(`{"reason":"no passed"}`); err == nil {
|
||||
if _, err := gates.ParseVerdict(`{"reason":"no passed"}`); err == nil {
|
||||
t.Fatal("missing passed should fail")
|
||||
}
|
||||
}
|
||||
@@ -392,7 +393,7 @@ func TestFalseDeliverableFixtures(t *testing.T) {
|
||||
}
|
||||
for _, c := range doc.Cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
block, reason := deliverableGate(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
|
||||
block, reason := deliverableGateOf(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
|
||||
if !block || strings.TrimSpace(reason) == "" {
|
||||
t.Fatalf("gate = %v %q", block, reason)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"agent-system-backend/internal/docgen"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/wfcompile"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -214,7 +215,8 @@ func (m *MockRunner) Execute(rc RunCtx) error {
|
||||
}
|
||||
|
||||
case strings.Contains(user, "#workflow"):
|
||||
res, err := (WorkflowRunner{}).run(rc, workflow.Launch{Def: mockWorkflow()})
|
||||
// Q3 WP2:演示工作流同走编译图(纯宿主步,不依赖模型后端)。
|
||||
res, err := (CompiledWorkflowRunner{}).runLaunch(rc, workflow.Launch{Def: mockWorkflow()}, wfcompile.RunOptions{})
|
||||
if err != nil {
|
||||
if res.Summary != "" {
|
||||
addText(res.Summary)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// PermissionAskOf 只在宿主实现内核级询问时接线;否则返回 nil,
|
||||
// 内核保持「ask 按拒绝处理」的旧行为。
|
||||
func TestPermissionAskOfOptional(t *testing.T) {
|
||||
type plain struct{ RunCtx }
|
||||
if got := PermissionAskOf(plain{}); got != nil {
|
||||
t.Fatal("plain rc returned a non-nil ask port, want nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionAskOfWired(t *testing.T) {
|
||||
type asker struct{ RunCtx }
|
||||
wired := PermissionAskOf(askStub{})
|
||||
if wired == nil {
|
||||
t.Fatal("wired rc returned nil")
|
||||
}
|
||||
if v := wired(context.Background(), agentllm.ToolCall{Name: "run_shell"}); !v.Allow {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
type askStub struct{ RunCtx }
|
||||
|
||||
func (askStub) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
|
||||
return nruntime.AskVerdict{Allow: true}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// P4:计划提醒常开——没有任何计划/待办而任务变长时,给一次可忽略的轻提醒;
|
||||
// 已有计划但停滞的旧提醒行为不变。
|
||||
func TestPlanlessNudgeFiresOnce(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
}
|
||||
quiet := textDeltaTurn("", toolCall("make_report", "{}"))
|
||||
turns := [][]interface{}{}
|
||||
_ = turns
|
||||
st := &directRun{l: &LLMRunner{}, rc: rc, eff: core.EffectiveSettings{}}
|
||||
var seen []string
|
||||
emitReminder := func(turn int) []string {
|
||||
// 模拟连续工具轮推进。
|
||||
st.closedToolTurn = true
|
||||
st.toolTurnsSince++
|
||||
return st.reminders(turn)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
seen = append(seen, emitReminder(i)...)
|
||||
}
|
||||
if len(seen) != 1 || !strings.Contains(seen[0], "update_plan") {
|
||||
t.Fatalf("planless nudge should fire exactly once, got %v", seen)
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
seen = append(seen, emitReminder(i)...)
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("planless nudge must not repeat, got %v", seen)
|
||||
}
|
||||
_ = quiet
|
||||
}
|
||||
@@ -5,6 +5,9 @@ package runtime
|
||||
import (
|
||||
"context"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -56,3 +59,18 @@ type Runner interface {
|
||||
Name() string
|
||||
Execute(rc RunCtx) error
|
||||
}
|
||||
|
||||
// PermissionAskOf 把宿主的内核级权限询问(runmgr 的 HITL 确认卡)接成
|
||||
// nruntime.Config.PermissionAsk 端口;宿主未实现时返回 nil,内核保持
|
||||
// 「ask 按拒绝处理」的旧行为(N1 WP2 装配;直连/子代理/小队共用)。
|
||||
// 入参为 any:直连/子代理传 RunCtx,小队装配传 squadadapter.Ctx——
|
||||
// 判定只依赖可选的 PermissionAsk 方法,不要求实现整个 RunCtx。
|
||||
func PermissionAskOf(rc any) func(context.Context, agentllm.ToolCall) nruntime.AskVerdict {
|
||||
asker, ok := rc.(interface {
|
||||
PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict
|
||||
})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return asker.PermissionAsk
|
||||
}
|
||||
|
||||
@@ -53,7 +53,11 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
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})
|
||||
sink.AddUsage(core.Usage{
|
||||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user