Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,14 @@ 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(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
|
||||
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,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -60,6 +61,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,
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
@@ -76,6 +78,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 +104,12 @@ 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, "")
|
||||
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,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 65aaebca1e...343b1e6d9a
Vendored
+1
-1
Submodule deps/agent-native updated: 3a110a728c...37c5e6fbd6
Vendored
+1
-1
Submodule deps/agent-squad updated: 5a57636cc0...819b0c5f82
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("空批不得达标(无实测分数)")
|
||||
}
|
||||
}
|
||||
+70
-5
@@ -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,16 @@ 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
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -83,7 +94,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 +122,12 @@ 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)")
|
||||
}
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
@@ -123,6 +138,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()
|
||||
@@ -255,6 +274,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 +316,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 +351,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 +385,10 @@ 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-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 +412,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
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -65,6 +66,11 @@ 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")
|
||||
}
|
||||
|
||||
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,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))
|
||||
}
|
||||
}
|
||||
+103
-4
@@ -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,9 +18,46 @@ 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 时用管线默认值。
|
||||
@@ -39,6 +79,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 +130,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 +148,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 +207,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")
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,16 @@ const cardAppendWindow = 250 * time.Millisecond
|
||||
type Service struct {
|
||||
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 实现)。
|
||||
@@ -275,7 +277,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")
|
||||
}
|
||||
|
||||
+21
-12
@@ -26,6 +26,8 @@ type Config struct {
|
||||
Instance string `json:"instance"`
|
||||
InsecureListen bool `json:"insecureListen"`
|
||||
AllowedHostsRaw string `json:"allowedHosts"`
|
||||
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
|
||||
DisableExecTools bool `json:"disableExecTools"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -37,16 +39,17 @@ 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",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
}
|
||||
|
||||
// Source 配置来源层级(数值越大优先级越高)。
|
||||
@@ -165,6 +168,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.InsecureListen
|
||||
case "allowedHosts":
|
||||
dst = &c.AllowedHostsRaw
|
||||
case "disableExecTools":
|
||||
dst = &c.DisableExecTools
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -199,13 +204,15 @@ func stringField(c Config, f string) string {
|
||||
}
|
||||
|
||||
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 +222,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
|
||||
}
|
||||
@@ -259,7 +268,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"} {
|
||||
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) {
|
||||
|
||||
@@ -207,12 +207,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 +226,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 +241,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 {
|
||||
|
||||
@@ -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("前缀变化后哈希必须不同")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/memory"
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// 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,52 @@ 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,
|
||||
|
||||
+159
-23
@@ -15,14 +15,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,7 +46,7 @@ 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 = 未启用,行为回退)
|
||||
@@ -78,6 +82,7 @@ const maxBackgroundChildren = 3
|
||||
|
||||
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
|
||||
type bgNotice struct {
|
||||
id string // 持久化队列主键(重启重投凭据)
|
||||
owner string
|
||||
name string
|
||||
payload core.AgentResultPayload
|
||||
@@ -96,6 +101,10 @@ type runHandle struct {
|
||||
started time.Time
|
||||
promptTokens int
|
||||
completionTokens int
|
||||
lastPromptTokens int
|
||||
lastCompletionTokens int
|
||||
cacheReadTokens int
|
||||
cacheWriteTokens int
|
||||
}
|
||||
|
||||
type hitlAnswer struct {
|
||||
@@ -231,10 +240,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 +300,59 @@ 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())
|
||||
out := make([]core.ToolDesc, 0, len(list))
|
||||
fs := c.eff.GetBool("runtime.fsTools", false)
|
||||
code := c.eff.GetBool("runtime.codeOrchestration", false)
|
||||
@@ -678,29 +743,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()}
|
||||
}
|
||||
@@ -866,7 +920,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
|
||||
@@ -1035,6 +1089,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 +1285,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 +1370,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 +1408,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 +1447,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 +1513,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 +1530,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 +1599,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 +1670,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,224 @@
|
||||
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
|
||||
}
|
||||
+64
-45
@@ -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"
|
||||
"agent-native/runtime/policy"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/quality"
|
||||
agentmemory "agent-native/runtime/memory"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -203,9 +205,10 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
|
||||
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 引用时检索注入(最小实现:显式引用)。
|
||||
@@ -227,9 +230,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
}
|
||||
// 工件正文回填(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())
|
||||
}
|
||||
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
|
||||
// 组装侧不再注入(防双份;注入文案同一形态)。
|
||||
_ = ctxasm.InjectMemory
|
||||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
|
||||
tail := ctxasm.Tail{
|
||||
Instructions: eff.GetString("context.userInstructions", ""),
|
||||
@@ -398,6 +401,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 +478,55 @@ 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, ""
|
||||
}
|
||||
|
||||
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
|
||||
func qualityScorer() gates.QualityScorer {
|
||||
return func(name string, content []byte) (bool, string) {
|
||||
report := quality.ScoreBytes(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 {
|
||||
|
||||
@@ -3,18 +3,22 @@ package runtime
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
"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 +30,7 @@ type directRun struct {
|
||||
rc RunCtx
|
||||
eff core.EffectiveSettings
|
||||
cardflow bool
|
||||
memoryStore memoryStore
|
||||
finalParts []core.Part
|
||||
explicitCard bool
|
||||
deliverables []*core.ArtifactRef
|
||||
@@ -51,9 +56,8 @@ type directRun struct {
|
||||
closedToolTurn bool
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
checklistReminders int
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
notes *reminders.Bus
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
@@ -90,6 +94,10 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
|
||||
|
||||
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 {
|
||||
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
|
||||
@@ -159,6 +167,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
}
|
||||
|
||||
window, reserve, buffer := contextWindow(st.eff)
|
||||
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对——
|
||||
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线
|
||||
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无
|
||||
// Session(旧行为,不阻断对话)。
|
||||
var sessStore *agentsession.Store
|
||||
if sandbox := conversationSandbox(rc); sandbox != "" {
|
||||
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil {
|
||||
sessStore = opened
|
||||
} else {
|
||||
log.Printf("[runtime] open session transcript: %v", oerr)
|
||||
}
|
||||
}
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.Stop {
|
||||
@@ -173,6 +193,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,
|
||||
@@ -186,7 +208,11 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
OnEvent: st.onEvent,
|
||||
Hooks: bus,
|
||||
Reminders: st.reminders,
|
||||
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)
|
||||
},
|
||||
@@ -251,6 +277,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 +319,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 +381,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,7 +394,8 @@ 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 {
|
||||
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
|
||||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
|
||||
@@ -424,7 +459,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)}
|
||||
@@ -594,7 +629,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 +643,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 +758,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,166 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// JournalOnly 语义:上下文始终由 store 历史组装(每跑都在、恰好一次、
|
||||
// 不丢不重);journal 不吞种子历史。
|
||||
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)
|
||||
}
|
||||
for _, m := range loaded {
|
||||
if m.Content == "历史一" {
|
||||
t.Fatalf("journal 不得吞种子历史:%#v", 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"
|
||||
)
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +108,15 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
OutputReserve: reserve,
|
||||
ContextBuffer: buffer,
|
||||
OnEvent: st.onEvent,
|
||||
PermissionAsk: PermissionAskOf(rc),
|
||||
Trace: l.Trace,
|
||||
AgentName: agentNameOf(rc.Profile()),
|
||||
RetryAttempts: retryAttemptsOf(rc.Settings()),
|
||||
}, user)
|
||||
// 子代理记账(N3 WP6):token 用量入账(此前只有 DurationMs)。
|
||||
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
|
||||
sink.AddUsage(st.usageTotal)
|
||||
}
|
||||
if len(st.parts) == 0 {
|
||||
st.parts = append(st.parts, core.Part{Type: core.PartText, Text: "(子代理未返回内容)"})
|
||||
}
|
||||
@@ -167,11 +174,12 @@ type subSlot struct {
|
||||
}
|
||||
|
||||
type subRun struct {
|
||||
rc RunCtx
|
||||
parts []core.Part
|
||||
queued []agentllm.ToolCall
|
||||
inflight *subSlot
|
||||
turnText strings.Builder
|
||||
rc RunCtx
|
||||
parts []core.Part
|
||||
queued []agentllm.ToolCall
|
||||
inflight *subSlot
|
||||
turnText strings.Builder
|
||||
usageTotal core.Usage
|
||||
}
|
||||
|
||||
func (st *subRun) onEvent(ev nruntime.Event) {
|
||||
@@ -182,6 +190,13 @@ func (st *subRun) onEvent(ev nruntime.Event) {
|
||||
st.turnText.WriteString(ev.Text)
|
||||
_ = st.rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: st.rc.Run().ID, Note: truncateRun(st.turnText.String(), 80)})
|
||||
case "message_end":
|
||||
st.usageTotal.PromptTokens += ev.PromptTokens
|
||||
st.usageTotal.CompletionTokens += ev.CompletionTokens
|
||||
st.usageTotal.TotalTokens += ev.TotalTokens
|
||||
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 body := strings.TrimSpace(st.turnText.String()); body != "" {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- N3 WP6:计费拆分与重试收口(backend 侧)----
|
||||
|
||||
// retryAttemptsOf 映射:设置 0=不重试→-1;N→N;默认 2→2。
|
||||
func TestRetryAttemptsMapping(t *testing.T) {
|
||||
if got := retryAttemptsOf(core.EffectiveSettings{}); got != 2 {
|
||||
t.Fatalf("默认应 2,得到 %d", got)
|
||||
}
|
||||
if got := retryAttemptsOf(core.EffectiveSettings{"runtime": map[string]any{"llmRetries": float64(0)}}); got != -1 {
|
||||
t.Fatalf("设置 0 应映射为关闭(-1),得到 %d", got)
|
||||
}
|
||||
if got := retryAttemptsOf(core.EffectiveSettings{"runtime": map[string]any{"llmRetries": float64(5)}}); got != 5 {
|
||||
t.Fatalf("设置 5 应映射为 5,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 直连 run 的用量拆分:message_end 到达后 Last* 覆盖、cache 累计入账。
|
||||
func TestDirectRunUsageSplitOnRecord(t *testing.T) {
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
|
||||
stream := func(ctx context.Context, model string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 1)
|
||||
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
|
||||
Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "x", StopReason: agentllm.StopEnd},
|
||||
Usage: agentllm.Usage{PromptTokens: 120, CompletionTokens: 12, TotalTokens: 132, CacheReadTokens: 80, CacheWriteTokens: 6},
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
r := &LLMRunner{Stream: stream}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
u := rc.usageTotal
|
||||
if u.PromptTokens != 120 || u.CompletionTokens != 12 ||
|
||||
u.LastPromptTokens != 120 || u.LastCompletionTokens != 12 ||
|
||||
u.CacheReadTokens != 80 || u.CacheWriteTokens != 6 {
|
||||
t.Fatalf("用量拆分未入账:%+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// 子代理记账:subRun 的 message_end 累计 token 并经 AddUsage 入账。
|
||||
func TestSubRunAccountsTokens(t *testing.T) {
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
|
||||
stream := func(ctx context.Context, model string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 1)
|
||||
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
|
||||
Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "子结果", StopReason: agentllm.StopEnd},
|
||||
Usage: agentllm.Usage{PromptTokens: 66, CompletionTokens: 6, TotalTokens: 72, CacheReadTokens: 30},
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
r := &SubLLMRunner{Stream: stream}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
u := rc.usageTotal
|
||||
if u.PromptTokens != 66 || u.CompletionTokens != 6 ||
|
||||
u.LastPromptTokens != 66 || u.LastCompletionTokens != 6 || u.CacheReadTokens != 30 {
|
||||
t.Fatalf("子代理用量未入账:%+v", u)
|
||||
}
|
||||
}
|
||||
+27
-127
@@ -1,27 +1,15 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-native/runtime/reminders"
|
||||
"agent-system-backend/internal/quality"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxVerifyContinuations 与内核 Stop 的 3 次上限对齐。
|
||||
maxVerifyContinuations = 3
|
||||
// reminderToolGap 连续这么多个工具轮没有更新计划或待办,就提醒一次。
|
||||
reminderToolGap = 3
|
||||
// maxChecklistReminders 每个 run 最多提醒这么多次。
|
||||
maxChecklistReminders = 3
|
||||
)
|
||||
|
||||
const verifyCapNotice = "本轮回复未通过终轮检查。"
|
||||
|
||||
const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)"
|
||||
@@ -37,61 +25,18 @@ func systemCheckReason(body string) string {
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
|
||||
}
|
||||
|
||||
type verifyVerdict struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
NextAction string `json:"nextAction"`
|
||||
}
|
||||
|
||||
// checkDeliverables 确认本轮登记的工件存在且非空。
|
||||
// md、txt、docx 还要过 quality;其他类型只检查存在与非空。
|
||||
func checkDeliverables(rc RunCtx, refs []*core.ArtifactRef) []string {
|
||||
var reasons []string
|
||||
for _, ref := range refs {
|
||||
if ref == nil || ref.ArtifactID == "" {
|
||||
reasons = append(reasons, "工件引用缺少 ID")
|
||||
continue
|
||||
}
|
||||
art, content, ok := rc.Artifact(ref.ArtifactID)
|
||||
name := ref.Name
|
||||
if name == "" {
|
||||
name = string(ref.ArtifactID)
|
||||
}
|
||||
if !ok {
|
||||
reasons = append(reasons, name+" 不存在")
|
||||
continue
|
||||
}
|
||||
if art.Name != "" {
|
||||
name = art.Name
|
||||
}
|
||||
if len(content) == 0 {
|
||||
reasons = append(reasons, name+" 内容为空")
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".md", ".txt", ".docx":
|
||||
report := quality.ScoreBytes(name, content)
|
||||
if !report.Passed {
|
||||
why := strings.Join(report.Violations, ";")
|
||||
if why == "" {
|
||||
why = "评分未通过"
|
||||
}
|
||||
reasons = append(reasons, name+":"+why)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (verifyVerdict, error) {
|
||||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
stream := l.VerifyStream
|
||||
if stream == nil {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器调用失败")
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
stream = client.Stream
|
||||
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
|
||||
stream = agentllm.WithFallback(
|
||||
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
|
||||
ChatFallbacks(rc.Settings()), nil)
|
||||
}
|
||||
model := route.Model
|
||||
if model == "" {
|
||||
@@ -107,16 +52,16 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
|
||||
case agentllm.KindTextDelta:
|
||||
b.WriteString(ev.Text)
|
||||
case agentllm.KindError:
|
||||
return verifyVerdict{}, fmt.Errorf("验证器调用失败")
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
case agentllm.KindMessageEnd:
|
||||
if b.Len() == 0 {
|
||||
b.WriteString(ev.Message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := parseVerdict(b.String())
|
||||
verdict, err := gates.ParseVerdict(b.String())
|
||||
if err != nil {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
return gates.Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
@@ -170,41 +115,6 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseVerdict(raw string) (verifyVerdict, error) {
|
||||
s := stripFence(raw)
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end < start {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
s = s[start : end+1]
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &probe); err != nil {
|
||||
return verifyVerdict{}, err
|
||||
}
|
||||
if _, ok := probe["passed"]; !ok {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
var verdict verifyVerdict
|
||||
if err := json.Unmarshal([]byte(s), &verdict); err != nil {
|
||||
return verifyVerdict{}, err
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
func stripFence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
|
||||
return s
|
||||
}
|
||||
|
||||
func (st *directRun) loadChecklist() {
|
||||
if src, ok := st.rc.(interface{ Todos() (core.TodoList, bool) }); ok {
|
||||
if list, ok := src.Todos(); ok {
|
||||
@@ -230,49 +140,39 @@ func (st *directRun) reminders(turn int) []string {
|
||||
st.closedToolTurn = false
|
||||
st.checklistTouched = false
|
||||
}
|
||||
if st.toolTurnsSince < reminderToolGap || st.checklistReminders >= maxChecklistReminders {
|
||||
if st.toolTurnsSince < gates.ReminderToolGap {
|
||||
return nil
|
||||
}
|
||||
text := st.unfinishedReminder()
|
||||
if text == "" {
|
||||
return nil
|
||||
// N1 WP5:节流、封顶与文案走下沉的 ProgressReminder;
|
||||
// 工具轮计数留在这里(它与 directRun 的事件流耦合)。
|
||||
if st.progress == nil {
|
||||
st.progress = &gates.ProgressReminder{}
|
||||
}
|
||||
if st.notes == nil {
|
||||
st.notes = reminders.New(0, maxChecklistReminders)
|
||||
todos, plans := st.unfinishedItems()
|
||||
got := st.progress.Remind(todos, plans, time.Now())
|
||||
if got != nil {
|
||||
st.toolTurnsSince = 0
|
||||
}
|
||||
key := fmt.Sprintf("%d:%s", st.checklistReminders, text)
|
||||
if !st.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, time.Now()) {
|
||||
return nil
|
||||
}
|
||||
st.toolTurnsSince = 0
|
||||
st.checklistReminders++
|
||||
return []string{text}
|
||||
return got
|
||||
}
|
||||
|
||||
func (st *directRun) unfinishedReminder() string {
|
||||
var lines []string
|
||||
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
|
||||
func (st *directRun) unfinishedItems() (todos, plans []string) {
|
||||
for _, item := range st.todos.Items {
|
||||
if item.Status == "done" {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(item.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
if text := strings.TrimSpace(item.Text); text != "" {
|
||||
todos = append(todos, text)
|
||||
}
|
||||
lines = append(lines, "- 待办:"+text)
|
||||
}
|
||||
for _, step := range st.plan.Steps {
|
||||
if step.Status == "done" || step.Status == "skipped" {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(step.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
if text := strings.TrimSpace(step.Text); text != "" {
|
||||
plans = append(plans, text)
|
||||
}
|
||||
lines = append(lines, "- 计划:"+text)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "计划和待办已连续 3 个工具轮没有更新。未完成项:\n" + strings.Join(lines, "\n") + "\n请更新计划或待办,或在回复里说明为什么先不做。"
|
||||
return todos, plans
|
||||
}
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const workflowPlannerID = "agent.sub.workflow-plan"
|
||||
|
||||
// WorkflowRunner 执行启动时拷贝下来的工作流定义。
|
||||
type WorkflowRunner struct {
|
||||
// JudgeStream 注入验收模型。nil 时按轻量模型构造客户端。
|
||||
JudgeStream agentllm.StreamFunc
|
||||
// ScriptTimeout 覆盖脚本墙钟超时。0 表示 600 秒,超过 1800 秒会被截断。
|
||||
ScriptTimeout time.Duration
|
||||
}
|
||||
|
||||
func (WorkflowRunner) Name() string { return "workflow" }
|
||||
|
||||
type workflowSource interface {
|
||||
Workflow() (workflow.Launch, bool)
|
||||
}
|
||||
|
||||
type workflowOutcome struct {
|
||||
Summary string
|
||||
Parts []core.Part
|
||||
Artifacts []*core.ArtifactRef
|
||||
}
|
||||
|
||||
// planWorkflow 让规划子代理写出步骤,校验通过后必须经用户确认才执行。
|
||||
func planWorkflow(rc RunCtx, goal string, runner WorkflowRunner) (workflowOutcome, error) {
|
||||
sp, ok := rc.(interface {
|
||||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
})
|
||||
if !ok {
|
||||
return workflowOutcome{}, fmt.Errorf("workflow: 当前运行不能规划")
|
||||
}
|
||||
res, err := sp.SpawnAgent(workflowPlannerID, "规划", "把下面的目标规划成 WorkflowDef。只输出 JSON,不要完成目标本身。\n目标:"+goal, "")
|
||||
if err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
text := res.Text
|
||||
if text == "" {
|
||||
text = res.Summary
|
||||
}
|
||||
def, err := parseWorkflowDef(text)
|
||||
if err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for _, tool := range rc.Tools() {
|
||||
known[tool.Name] = true
|
||||
}
|
||||
if err := workflow.Validate(def, workflow.DynamicLimits(), func(name string) bool { return known[name] }); err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
resp, err := rc.AskHITL(core.HITLCard{
|
||||
Question: planPreview(def), Kind: core.HITLKindConfirm, AllowFreeInput: false,
|
||||
Choices: []core.HITLChoice{
|
||||
{ID: "yes", Label: "执行", Description: "按这些步骤做", Impact: "会调用工具并可能写入工件"},
|
||||
{ID: "no", Label: "取消", Description: "不执行", Impact: "不会改动任何东西"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
if resp.ChoiceID != "yes" {
|
||||
return workflowOutcome{Summary: "未确认,未执行"}, nil
|
||||
}
|
||||
return runner.run(rc, workflow.Launch{Def: def})
|
||||
}
|
||||
|
||||
func parseWorkflowDef(raw string) (core.WorkflowDef, error) {
|
||||
s := stripFence(raw)
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end < start {
|
||||
return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析")
|
||||
}
|
||||
var def core.WorkflowDef
|
||||
if err := json.Unmarshal([]byte(s[start:end+1]), &def); err != nil {
|
||||
return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析")
|
||||
}
|
||||
if strings.TrimSpace(def.ID) == "" {
|
||||
def.ID = "dynamic"
|
||||
}
|
||||
if strings.TrimSpace(def.Title) == "" {
|
||||
def.Title = "临时工作流"
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
func planPreview(def core.WorkflowDef) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "按这个计划执行「%s」?\n", def.Title)
|
||||
for i, step := range def.Steps {
|
||||
fmt.Fprintf(&b, "%d. %s(%s)\n", i+1, step.Title, step.Kind)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func (w WorkflowRunner) Execute(rc RunCtx) error {
|
||||
src, ok := rc.(workflowSource)
|
||||
if !ok {
|
||||
return fmt.Errorf("workflow: 当前运行没有工作流定义")
|
||||
}
|
||||
launch, ok := src.Workflow()
|
||||
if !ok {
|
||||
return fmt.Errorf("workflow: 当前运行没有工作流定义")
|
||||
}
|
||||
res, err := w.run(rc, launch)
|
||||
if len(res.Parts) > 0 {
|
||||
_ = rc.SetAssistantParts(res.Parts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (w WorkflowRunner) run(rc RunCtx, launch workflow.Launch) (workflowOutcome, error) {
|
||||
def := launch.Def
|
||||
vars := workflow.Vars{
|
||||
Input: currentUserText(rc),
|
||||
Slots: map[string]string{},
|
||||
Steps: map[string]string{},
|
||||
}
|
||||
for k, v := range launch.Slots {
|
||||
vars.Slots[k] = v
|
||||
}
|
||||
for _, missing := range launch.Missing {
|
||||
if rc.Context().Err() != nil {
|
||||
return workflowOutcome{}, rc.Context().Err()
|
||||
}
|
||||
resp, err := rc.AskHITL(core.HITLCard{
|
||||
Question: missing.Question, Kind: core.HITLKindInput, AllowFreeInput: true,
|
||||
})
|
||||
if err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
answer := strings.TrimSpace(resp.FreeText)
|
||||
if answer == "" {
|
||||
answer = resp.ChoiceID
|
||||
}
|
||||
vars.Slots[missing.Key] = answer
|
||||
}
|
||||
|
||||
plan := core.PlanSnapshot{Title: def.Title, Steps: make([]core.PlanStep, len(def.Steps))}
|
||||
for i, step := range def.Steps {
|
||||
plan.Steps[i] = core.PlanStep{ID: step.ID, Text: step.Title, Status: "pending"}
|
||||
}
|
||||
if err := rc.UpdatePlan(plan); err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
|
||||
wholeLeft := def.MaxRetries
|
||||
if wholeLeft <= 0 || wholeLeft > workflow.MaxWorkflowRetries {
|
||||
wholeLeft = workflow.MaxWorkflowRetries
|
||||
}
|
||||
var drops []string
|
||||
var arts []*core.ArtifactRef
|
||||
var prev string
|
||||
statuses := make([]string, len(def.Steps))
|
||||
for i := range def.Steps {
|
||||
if rc.Context().Err() != nil {
|
||||
return outcome(def, statuses, arts, drops, ""), rc.Context().Err()
|
||||
}
|
||||
step := def.Steps[i]
|
||||
plan.Steps[i].Status = "in_progress"
|
||||
if err := rc.UpdatePlan(plan); err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
rendered, err := workflow.RenderStep(step, vars)
|
||||
if err != nil {
|
||||
return outcome(def, statuses, arts, drops, err.Error()), err
|
||||
}
|
||||
output, stepArts, reason, err := w.runStepWithCritic(rc, def, rendered, vars, prev, launch.Scripts, &wholeLeft, &drops)
|
||||
arts = append(arts, stepArts...)
|
||||
if err != nil {
|
||||
statuses[i] = "failed"
|
||||
plan.Steps[i].Status = "skipped"
|
||||
_ = rc.UpdatePlan(plan)
|
||||
res := outcome(def, statuses, arts, drops, reason)
|
||||
return res, err
|
||||
}
|
||||
vars.Steps[step.ID] = output
|
||||
prev = output
|
||||
statuses[i] = "done"
|
||||
plan.Steps[i].Status = "done"
|
||||
if err := rc.UpdatePlan(plan); err != nil {
|
||||
return workflowOutcome{}, err
|
||||
}
|
||||
}
|
||||
res := outcome(def, statuses, arts, drops, "")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (w WorkflowRunner) runStepWithCritic(rc RunCtx, def core.WorkflowDef, step core.WorkflowStep, vars workflow.Vars, prev string, scripts map[string]string, wholeLeft *int, drops *[]string) (string, []*core.ArtifactRef, string, error) {
|
||||
stepRetries := 1
|
||||
if step.Critic != nil && step.Critic.MaxRetries > 0 {
|
||||
stepRetries = step.Critic.MaxRetries
|
||||
}
|
||||
if stepRetries > workflow.MaxStepRetries {
|
||||
stepRetries = workflow.MaxStepRetries
|
||||
}
|
||||
used := 0
|
||||
note := ""
|
||||
var arts []*core.ArtifactRef
|
||||
for {
|
||||
current := step
|
||||
if note != "" && current.Prompt != "" {
|
||||
current.Prompt = current.Prompt + "\n\n未通过验收:" + note
|
||||
} else if note != "" {
|
||||
current.Prompt = "未通过验收:" + note
|
||||
}
|
||||
output, got, err := w.runWorkflowStep(rc, current, vars, prev, scripts)
|
||||
arts = append(arts, got...)
|
||||
if err != nil {
|
||||
return output, arts, err.Error(), err
|
||||
}
|
||||
if step.Critic == nil || strings.TrimSpace(step.Critic.Criteria) == "" {
|
||||
return output, arts, "", nil
|
||||
}
|
||||
verdict := w.judgeStep(rc, def.Title, step.Critic.Criteria, output)
|
||||
if verdict.Passed {
|
||||
return output, arts, "", nil
|
||||
}
|
||||
why := verdict.Reason
|
||||
if verdict.NextAction != "" {
|
||||
why += "。下一步:" + verdict.NextAction
|
||||
}
|
||||
*drops = append(*drops, step.ID+":"+why)
|
||||
if used >= stepRetries || *wholeLeft <= 0 {
|
||||
return output, arts, "步骤 " + step.Title + " 未通过验收:" + why, fmt.Errorf("workflow: 步骤 %s 未通过验收", step.ID)
|
||||
}
|
||||
used++
|
||||
*wholeLeft--
|
||||
note = why
|
||||
}
|
||||
}
|
||||
|
||||
func (w WorkflowRunner) runWorkflowStep(rc RunCtx, step core.WorkflowStep, vars workflow.Vars, prev string, scripts map[string]string) (string, []*core.ArtifactRef, error) {
|
||||
switch step.Kind {
|
||||
case "prompt":
|
||||
return runPromptStep(rc, step)
|
||||
case "tool":
|
||||
res := rc.ExecuteTool(core.ToolCallRequest{Name: step.Tool, Arguments: step.Args})
|
||||
if res.Status == "error" {
|
||||
return res.Output, nil, fmt.Errorf("workflow: 工具 %s 失败:%s", step.Tool, res.Output)
|
||||
}
|
||||
var arts []*core.ArtifactRef
|
||||
if res.Artifact != nil {
|
||||
arts = append(arts, &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind})
|
||||
}
|
||||
return res.Output, arts, nil
|
||||
case "hitl":
|
||||
card := core.HITLCard{Question: step.Title, Kind: core.HITLKindInput, AllowFreeInput: true}
|
||||
if step.Hitl != nil {
|
||||
card.Question = step.Hitl.Question
|
||||
card.Kind = step.Hitl.Kind
|
||||
card.Choices = step.Hitl.Choices
|
||||
card.AllowFreeInput = step.Hitl.AllowFreeInput
|
||||
card.FreeInputHint = step.Hitl.FreeInputHint
|
||||
}
|
||||
resp, err := rc.AskHITL(card)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if strings.TrimSpace(resp.FreeText) != "" {
|
||||
return strings.TrimSpace(resp.FreeText), nil, nil
|
||||
}
|
||||
return resp.ChoiceID, nil, nil
|
||||
case "artifact":
|
||||
content := step.Prompt
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = prev
|
||||
}
|
||||
name := step.Artifact
|
||||
if name == "" {
|
||||
name = step.ID + ".md"
|
||||
}
|
||||
art, err := rc.WriteArtifact(name, "document", "text/markdown", []byte(content))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return string(art.ID), []*core.ArtifactRef{{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}, nil
|
||||
case "card":
|
||||
card := core.ActivityCard{Title: step.Title, Kind: core.CardKindResult}
|
||||
if step.Card != nil {
|
||||
card.Kind = step.Card.Kind
|
||||
card.Title = step.Card.Title
|
||||
card.Tone = step.Card.Tone
|
||||
card.Body = step.Card.Body
|
||||
card.Actions = step.Card.Actions
|
||||
}
|
||||
created, err := rc.CreateCard(card)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return string(created.ID), nil, nil
|
||||
case "script":
|
||||
return executeScriptStep(rc, w, step, scripts)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("workflow: 未知步骤类型 %s", step.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func runPromptStep(rc RunCtx, step core.WorkflowStep) (string, []*core.ArtifactRef, error) {
|
||||
agent := step.Agent
|
||||
if strings.TrimSpace(agent) == "" {
|
||||
agent = "general"
|
||||
}
|
||||
var res core.AgentResultPayload
|
||||
var err error
|
||||
if sp, ok := rc.(interface {
|
||||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
}); ok {
|
||||
res, err = sp.SpawnAgent(subagentProfileID(agent), step.Title, step.Prompt, "")
|
||||
} else {
|
||||
res, err = rc.SpawnSubagent(step.Title, step.Prompt, "")
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
text := res.Text
|
||||
if text == "" {
|
||||
text = res.Summary
|
||||
}
|
||||
return text, artifactsIn(rc, text), nil
|
||||
}
|
||||
|
||||
func artifactsIn(rc RunCtx, text string) []*core.ArtifactRef {
|
||||
var out []*core.ArtifactRef
|
||||
seen := map[string]bool{}
|
||||
for _, id := range artIDRe.FindAllString(text, -1) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
art, _, ok := rc.Artifact(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) verifyVerdict {
|
||||
stream := w.JudgeStream
|
||||
model := "verify"
|
||||
if stream == nil {
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"}
|
||||
}
|
||||
model = route.Model
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
stream = client.Stream
|
||||
}
|
||||
prompt := fmt.Sprintf("你是验收员。目标:%s\n验收标准:%s\n这一步的产出:\n%s\n标准里要求出现的句子,产出里必须逐字包含,否则 passed 必须为 false,nextAction 写出要补上的原句。只输出一个 JSON 对象,字段为 passed、reason、nextAction。", goal, criteria, output)
|
||||
var b strings.Builder
|
||||
for ev := range stream(rc.Context(), model, []agentllm.Message{{Role: agentllm.RoleUser, Content: prompt}}, nil, agentllm.StreamOptions{}) {
|
||||
switch ev.Kind {
|
||||
case agentllm.KindTextDelta:
|
||||
b.WriteString(ev.Text)
|
||||
case agentllm.KindError:
|
||||
return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "稍后再试"}
|
||||
case agentllm.KindMessageEnd:
|
||||
if b.Len() == 0 {
|
||||
b.WriteString(ev.Message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := parseVerdict(b.String())
|
||||
if err != nil {
|
||||
return verifyVerdict{Passed: false, Reason: "无法解析", NextAction: "按验收标准重做"}
|
||||
}
|
||||
return verdict
|
||||
}
|
||||
|
||||
func outcome(def core.WorkflowDef, statuses []string, arts []*core.ArtifactRef, drops []string, stopped string) workflowOutcome {
|
||||
var b strings.Builder
|
||||
title := def.Title
|
||||
if title == "" {
|
||||
title = def.ID
|
||||
}
|
||||
if stopped != "" {
|
||||
fmt.Fprintf(&b, "工作流「%s」停止:%s\n", title, stopped)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "工作流「%s」完成。\n", title)
|
||||
}
|
||||
for i, step := range def.Steps {
|
||||
state := "pending"
|
||||
if i < len(statuses) && statuses[i] != "" {
|
||||
state = statuses[i]
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s:%s\n", step.Title, state)
|
||||
}
|
||||
if len(arts) > 0 {
|
||||
b.WriteString("工件:")
|
||||
for i, art := range arts {
|
||||
if i > 0 {
|
||||
b.WriteString("、")
|
||||
}
|
||||
b.WriteString(string(art.ArtifactID))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(drops) > 0 {
|
||||
b.WriteString("已丢弃:")
|
||||
b.WriteString(strings.Join(drops, ";"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
parts := []core.Part{{Type: core.PartText, Text: strings.TrimRight(b.String(), "\n")}}
|
||||
for _, art := range arts {
|
||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: art})
|
||||
}
|
||||
return workflowOutcome{Summary: parts[0].Text, Parts: parts, Artifacts: arts}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/wfcompile"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// workflow_compiled.go — 工作流执行器(Q3 WP2):WorkflowDef 一律经
|
||||
// internal/wfcompile 编译为 squad 图执行(legacy 逐步循环 + SpawnSubagent
|
||||
// 压缩路径已退役)。P4 语义由编译入口与宿主处理器持有:步数上限、动态
|
||||
// 工作流禁 script 且必须确认、模型脚本必须确认、runtime.workflowScripts
|
||||
// 默认关、critic 每步 ≤3 整条 ≤6、脚本回调限额、DeniedTools。
|
||||
|
||||
const workflowPlannerID = "agent.sub.workflow-plan"
|
||||
|
||||
// workflowSource 由 runmgr 的 runCtx 实现:run 启动时拷贝的工作流定义。
|
||||
type workflowSource interface {
|
||||
Workflow() (workflow.Launch, bool)
|
||||
}
|
||||
|
||||
// CompiledWorkflowRunner 以编译图执行启动时绑定的工作流(意图/演示入口)。
|
||||
type CompiledWorkflowRunner struct {
|
||||
// JudgeStream 注入验收模型;nil 时按轻量模型配置构造,仍缺则验证器不可用。
|
||||
JudgeStream agentllm.StreamFunc
|
||||
// ScriptTimeout 覆盖脚本墙钟超时(0 = 600s;1800s 上限由宿主持有)。
|
||||
ScriptTimeout time.Duration
|
||||
}
|
||||
|
||||
func (CompiledWorkflowRunner) Name() string { return "workflow" }
|
||||
|
||||
func (w CompiledWorkflowRunner) Execute(rc RunCtx) error {
|
||||
src, ok := rc.(workflowSource)
|
||||
if !ok {
|
||||
return fmt.Errorf("workflow: 当前运行没有工作流定义")
|
||||
}
|
||||
launch, ok := src.Workflow()
|
||||
if !ok {
|
||||
return fmt.Errorf("workflow: 当前运行没有工作流定义")
|
||||
}
|
||||
out, err := w.runLaunch(rc, launch, wfcompile.RunOptions{})
|
||||
if len(out.Parts) > 0 {
|
||||
_ = rc.SetAssistantParts(out.Parts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// runLaunch 装配编译图执行依赖并跑一次工作流(三个 run_workflow 分支与
|
||||
// 意图入口共用)。
|
||||
func (w CompiledWorkflowRunner) runLaunch(rc RunCtx, launch workflow.Launch, opts wfcompile.RunOptions) (wfcompile.Outcome, error) {
|
||||
deps := wfcompile.Deps{
|
||||
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
|
||||
Backends: squadadapter.BuildBackends(rc),
|
||||
DefaultBackend: "agent-native",
|
||||
JudgeStream: w.JudgeStream,
|
||||
ScriptTimeout: w.ScriptTimeout,
|
||||
}
|
||||
if deps.JudgeStream == nil {
|
||||
deps.JudgeStream = judgeStreamFor(rc)
|
||||
}
|
||||
return wfcompile.RunCompiled(workflowHost{rc: rc}, deps, launch, workflow.Vars{
|
||||
Input: currentUserText(rc),
|
||||
Slots: map[string]string{},
|
||||
Steps: map[string]string{},
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// judgeStreamFor 按轻量模型配置构造验收流(legacy judgeStep 回退路径同款:
|
||||
// 模型名取路由结果;未配置 = nil,wfcompile 按「验证器不可用」fail-closed)。
|
||||
func judgeStreamFor(rc RunCtx) agentllm.StreamFunc {
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return nil
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
model := route.Model
|
||||
return func(ctx context.Context, _ string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
return client.Stream(ctx, model, msgs, tools, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// planCompiledWorkflow 规划分支(run_workflow goal=):规划子代理产出
|
||||
// WorkflowDef → 动态上限编译 → 执行前强制确认(legacy planWorkflow 同款流)。
|
||||
func planCompiledWorkflow(rc RunCtx, goal string, runner CompiledWorkflowRunner) (wfcompile.Outcome, error) {
|
||||
sp, ok := rc.(interface {
|
||||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
})
|
||||
if !ok {
|
||||
return wfcompile.Outcome{}, fmt.Errorf("workflow: 当前运行不能规划")
|
||||
}
|
||||
res, err := sp.SpawnAgent(workflowPlannerID, "规划", "把下面的目标规划成 WorkflowDef。只输出 JSON,不要完成目标本身。\n目标:"+goal, "")
|
||||
if err != nil {
|
||||
return wfcompile.Outcome{}, err
|
||||
}
|
||||
text := res.Text
|
||||
if text == "" {
|
||||
text = res.Summary
|
||||
}
|
||||
def, err := wfcompile.ParseWorkflowDef(text)
|
||||
if err != nil {
|
||||
return wfcompile.Outcome{}, err
|
||||
}
|
||||
return runner.runLaunch(rc, workflow.Launch{Def: def}, wfcompile.RunOptions{
|
||||
DynamicLimits: true, RequireConfirm: true,
|
||||
})
|
||||
}
|
||||
|
||||
// registeredWorkflowLaunch 组装注册工作流的启动副本(脚本正文取装载快照,
|
||||
// legacy 同款:插件后续变更不影响已启动运行)。
|
||||
func registeredWorkflowLaunch(rc RunCtx, def core.WorkflowDef, slots map[string]string) workflow.Launch {
|
||||
launch := workflow.Launch{Def: def, Slots: slots, Scripts: map[string]string{}}
|
||||
lookup, ok := rc.(interface {
|
||||
WorkflowScript(id, path string) (string, bool)
|
||||
})
|
||||
if !ok {
|
||||
return launch
|
||||
}
|
||||
for _, step := range def.Steps {
|
||||
if step.Kind != "script" || strings.TrimSpace(step.Script) == "" {
|
||||
continue
|
||||
}
|
||||
if body, found := lookup.WorkflowScript(def.ID, step.Script); found {
|
||||
launch.Scripts[step.Script] = body
|
||||
}
|
||||
}
|
||||
return launch
|
||||
}
|
||||
|
||||
// modelScriptLaunch 把模型提供的脚本正文包成单步工作流(script 分支;
|
||||
// 确认与开关语义由 wfcompile 宿主处理器持有)。
|
||||
func modelScriptLaunch(body string) workflow.Launch {
|
||||
def := core.WorkflowDef{ID: "model.script", Title: "模型脚本", Steps: []core.WorkflowStep{
|
||||
{ID: "run", Title: "跑脚本", Kind: "script", Script: "model.py"},
|
||||
}}
|
||||
return workflow.Launch{Def: def, Scripts: map[string]string{"model.py": body}}
|
||||
}
|
||||
|
||||
// workflowHost 把 RunCtx 适配到 wfcompile.Host(ConversationSandbox 是
|
||||
// 运行侧可选能力,缺失时回退空串由宿主处理器 fail-closed)。
|
||||
type workflowHost struct{ rc RunCtx }
|
||||
|
||||
func (h workflowHost) Context() context.Context { return h.rc.Context() }
|
||||
func (h workflowHost) Run() core.Run { return h.rc.Run() }
|
||||
func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() }
|
||||
func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) }
|
||||
func (h workflowHost) AskHITL(c core.HITLCard) (core.HITLResponse, error) { return h.rc.AskHITL(c) }
|
||||
func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) }
|
||||
func (h workflowHost) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
return h.rc.WriteArtifact(name, kind, mime, content)
|
||||
}
|
||||
func (h workflowHost) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
return h.rc.ExecuteTool(call)
|
||||
}
|
||||
func (h workflowHost) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
return h.rc.CreateCard(card)
|
||||
}
|
||||
func (h workflowHost) Tools() []core.ToolDesc { return h.rc.Tools() }
|
||||
func (h workflowHost) ConversationSandbox() string {
|
||||
if sb, ok := h.rc.(interface{ ConversationSandbox() string }); ok {
|
||||
return sb.ConversationSandbox()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+95
-147
@@ -10,10 +10,16 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// workflow_compiled_test.go — Q3 WP2 行为一致性(P4):run_workflow 三分支
|
||||
// 改投编译图后的 runtime 级验证——注册工作流查找与错误面、规划分支确认门
|
||||
// 与动态上限、模型脚本开关、缺失槽位 HITL 收齐、验收模型路由。
|
||||
// (编译/宿主语义的细粒度断言在 internal/wfcompile 测试内。)
|
||||
|
||||
type wfFake struct {
|
||||
ctx context.Context
|
||||
launch workflow.Launch
|
||||
@@ -158,104 +164,9 @@ func (f *wfFake) WorkflowByID(id string) (core.WorkflowDef, bool) {
|
||||
return def, ok
|
||||
}
|
||||
|
||||
func TestWorkflowRunsFiveKindsAndAsksMissingSlot(t *testing.T) {
|
||||
f := &wfFake{hitl: []core.HITLResponse{{FreeText: "核能"}, {ChoiceID: "short"}}}
|
||||
f.launch = workflow.Launch{
|
||||
Missing: []workflow.SlotQuestion{{Key: "topic", Question: "主题是什么?"}},
|
||||
Def: core.WorkflowDef{ID: "demo.brief", Title: "简报", Steps: []core.WorkflowStep{
|
||||
{ID: "len", Title: "篇幅", Kind: "hitl", Hitl: &core.HITLCardTemplate{Question: "篇幅?", Kind: core.HITLKindChoice, Choices: []core.HITLChoice{{ID: "short", Label: "短"}}}},
|
||||
{ID: "draft", Title: "起草", Kind: "prompt", Agent: "research", Prompt: "写 {{slots.topic}}"},
|
||||
{ID: "now", Title: "时间", Kind: "tool", Tool: "time_now", Args: []byte(`{"q":"{{slots.topic}}"}`)},
|
||||
{ID: "save", Title: "存稿", Kind: "artifact", Artifact: "brief.md", Prompt: "{{steps.draft.output}}"},
|
||||
{ID: "note", Title: "通知", Kind: "card", Card: &core.ActivityCardTemplate{Kind: core.CardKindResult, Title: "好了"}},
|
||||
}},
|
||||
}
|
||||
if err := (WorkflowRunner{}).Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.hitlQ[0] != "主题是什么?" || f.hitlQ[1] != "篇幅?" {
|
||||
t.Fatalf("questions %v", f.hitlQ)
|
||||
}
|
||||
if len(f.prompts) != 1 || !strings.Contains(f.prompts[0], "agent.sub.research|写 核能") {
|
||||
t.Fatalf("prompt %v", f.prompts)
|
||||
}
|
||||
if len(f.tools) != 1 || string(f.tools[0].Arguments) == "" || !strings.Contains(string(f.tools[0].Arguments), "核能") {
|
||||
t.Fatalf("tool %#v", f.tools)
|
||||
}
|
||||
if len(f.writes) != 1 || f.writes[0] != "brief.md=正文 art_0123456789ab" {
|
||||
t.Fatalf("writes %v", f.writes)
|
||||
}
|
||||
if len(f.cards) != 1 || f.cards[0].Title != "好了" {
|
||||
t.Fatalf("cards %+v", f.cards)
|
||||
}
|
||||
var sawProgress, sawDone bool
|
||||
for _, p := range f.plans {
|
||||
for _, s := range p.Steps {
|
||||
if s.ID == "draft" && s.Status == "in_progress" {
|
||||
sawProgress = true
|
||||
}
|
||||
if s.ID == "note" && s.Status == "done" {
|
||||
sawDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawProgress || !sawDone {
|
||||
t.Fatal("plan did not move from in_progress to done")
|
||||
}
|
||||
text := f.assistant[0].Text
|
||||
if !strings.Contains(text, "art_0123456789ab") || !strings.Contains(text, "art_aaaaaaaaaaaa") {
|
||||
t.Fatalf("summary %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCriticRetriesThenStops(t *testing.T) {
|
||||
f := &wfFake{}
|
||||
f.launch = workflow.Launch{Def: core.WorkflowDef{
|
||||
ID: "a", Title: "验收", MaxRetries: 6,
|
||||
Steps: []core.WorkflowStep{{
|
||||
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
|
||||
Critic: &core.StepCritic{Criteria: "必须点明主题", MaxRetries: 1},
|
||||
}},
|
||||
}}
|
||||
runner := WorkflowRunner{JudgeStream: passedStream(
|
||||
`{"passed":false,"reason":"没点明主题","nextAction":"补上主题"}`,
|
||||
`{"passed":true,"reason":"可以","nextAction":""}`,
|
||||
)}
|
||||
if err := runner.Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.prompts) != 2 || !strings.Contains(f.prompts[1], "没点明主题") || !strings.Contains(f.prompts[1], "补上主题") {
|
||||
t.Fatalf("prompts %v", f.prompts)
|
||||
}
|
||||
if !strings.Contains(f.assistant[0].Text, "已丢弃") || !strings.Contains(f.assistant[0].Text, "没点明主题") {
|
||||
t.Fatalf("summary %s", f.assistant[0].Text)
|
||||
}
|
||||
|
||||
f2 := &wfFake{}
|
||||
f2.launch = f.launch
|
||||
bad := WorkflowRunner{JudgeStream: passedStream("不是 json", "不是 json")}
|
||||
err := bad.Execute(f2)
|
||||
if err == nil || len(f2.prompts) != 2 {
|
||||
t.Fatalf("err %v prompts %d", err, len(f2.prompts))
|
||||
}
|
||||
if !strings.Contains(f2.assistant[0].Text, "无法解析") {
|
||||
t.Fatalf("summary %s", f2.assistant[0].Text)
|
||||
}
|
||||
|
||||
f3 := &wfFake{}
|
||||
f3.launch = workflow.Launch{Def: core.WorkflowDef{
|
||||
ID: "a", Title: "验收", MaxRetries: 1,
|
||||
Steps: []core.WorkflowStep{{
|
||||
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
|
||||
Critic: &core.StepCritic{Criteria: "要过", MaxRetries: 3},
|
||||
}},
|
||||
}}
|
||||
fail := `{"passed":false,"reason":"不行","nextAction":"重写"}`
|
||||
stop := WorkflowRunner{JudgeStream: passedStream(fail, fail, fail)}
|
||||
if err := stop.Execute(f3); err == nil || len(f3.prompts) != 2 {
|
||||
t.Fatalf("whole cap err %v attempts %d", err, len(f3.prompts))
|
||||
}
|
||||
}
|
||||
// ---- run_workflow 注册分支:查找与错误面(legacy 同款文案) ----
|
||||
|
||||
func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
|
||||
f := &wfFake{hitl: []core.HITLResponse{{ChoiceID: "yes"}}}
|
||||
@@ -277,7 +188,82 @@ func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeUsesConfiguredModel(t *testing.T) {
|
||||
// ---- 规划分支:确认门(engine confirmation)+ 动态上限 fail-closed ----
|
||||
|
||||
func TestPlannedWorkflowNeedsConfirm(t *testing.T) {
|
||||
plan := `{"id":"plan.brief","title":"简报","steps":[{"id":"ask","title":"确认","kind":"hitl","hitl":{"question":"可以吗?","kind":"confirm","allowFreeInput":false}}]}`
|
||||
f := &wfFake{
|
||||
spawnText: plan,
|
||||
hitl: []core.HITLResponse{{ChoiceID: "approve"}, {ChoiceID: "ok"}},
|
||||
}
|
||||
st := &directRun{rc: f}
|
||||
out := st.runRegisteredWorkflow(`{"goal":"写一份简报"}`)
|
||||
if f.spawnIDs[0] != "agent.sub.workflow-plan" || !strings.Contains(f.hitlQ[0], "确认") {
|
||||
t.Fatalf("spawn %v questions %v", f.spawnIDs, f.hitlQ)
|
||||
}
|
||||
if !strings.Contains(out, "完成") || f.hitlQ[1] != "可以吗?" {
|
||||
t.Fatalf("out %s questions %v", out, f.hitlQ)
|
||||
}
|
||||
|
||||
// 计划确认被拒 → legacy 语义「未确认,未执行」。
|
||||
f2 := &wfFake{spawnText: plan, hitl: []core.HITLResponse{{ChoiceID: "abort"}}}
|
||||
out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"goal":"写一份简报"}`)
|
||||
if out != "未确认,未执行" || len(f2.hitlQ) != 1 {
|
||||
t.Fatalf("out %s questions %v", out, f2.hitlQ)
|
||||
}
|
||||
|
||||
// 动态上限:9 步 > 8,编译入口拒绝,不进任何 HITL。
|
||||
var steps []string
|
||||
for i := 0; i < 9; i++ {
|
||||
steps = append(steps, fmt.Sprintf(`{"id":"s%d","title":"步","kind":"hitl"}`, i))
|
||||
}
|
||||
f3 := &wfFake{spawnText: `{"id":"too.big","title":"太多","steps":[` + strings.Join(steps, ",") + `]}`}
|
||||
out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"goal":"做很多事"}`)
|
||||
if !strings.Contains(out, "超出上限") || len(f3.hitlQ) != 0 {
|
||||
t.Fatalf("out %s questions %v", out, f3.hitlQ)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 脚本分支:runtime.workflowScripts 开关(默认关) ----
|
||||
|
||||
func TestScriptBranchRespectsSwitch(t *testing.T) {
|
||||
st := &directRun{rc: &wfFake{sandbox: t.TempDir()}}
|
||||
out := st.runScriptWorkflow("import agentwf as wf\nwf.step('run','跑','done')\n")
|
||||
if !strings.Contains(out, "工作流脚本未开启") {
|
||||
t.Fatalf("开关默认关应失败:%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 缺失槽位:执行前 HITL 收齐并参与编译期渲染 ----
|
||||
|
||||
func TestCompiledRunnerAsksMissingSlot(t *testing.T) {
|
||||
f := &wfFake{hitl: []core.HITLResponse{{FreeText: "核能"}, {ChoiceID: "short"}}}
|
||||
f.launch = workflow.Launch{
|
||||
Missing: []workflow.SlotQuestion{{Key: "topic", Question: "主题是什么?"}},
|
||||
Def: core.WorkflowDef{ID: "slots", Title: "槽位", Steps: []core.WorkflowStep{
|
||||
{ID: "len", Title: "篇幅", Kind: "hitl", Hitl: &core.HITLCardTemplate{Question: "篇幅?", Kind: core.HITLKindChoice, Choices: []core.HITLChoice{{ID: "short", Label: "短"}}}},
|
||||
{ID: "save", Title: "存稿", Kind: "artifact", Artifact: "brief.md", Prompt: "主题:{{slots.topic}},篇幅:{{steps.len.output}}"},
|
||||
}},
|
||||
}
|
||||
if err := (CompiledWorkflowRunner{}).Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.hitlQ[0] != "主题是什么?" || f.hitlQ[1] != "篇幅?" {
|
||||
t.Fatalf("questions %v", f.hitlQ)
|
||||
}
|
||||
if len(f.writes) != 1 || !strings.HasPrefix(f.writes[0], "brief.md=") ||
|
||||
!strings.Contains(f.writes[0], "主题:核能") || !strings.Contains(f.writes[0], "short") {
|
||||
t.Fatalf("writes = %v", f.writes)
|
||||
}
|
||||
text := f.assistant[0].Text
|
||||
if !strings.Contains(text, "工作流「槽位」完成。") {
|
||||
t.Fatalf("summary %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 验收模型路由:轻量模型配置 → JudgeStream;未配置 → nil(fail-closed) ----
|
||||
|
||||
func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
@@ -288,60 +274,22 @@ func TestJudgeUsesConfiguredModel(t *testing.T) {
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &wfFake{
|
||||
settings: core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
|
||||
"baseUrl": srv.URL, "apiKey": "test-key", "model": "MiniMax-test",
|
||||
}}},
|
||||
launch: workflow.Launch{Def: core.WorkflowDef{
|
||||
ID: "c", Title: "验收",
|
||||
Steps: []core.WorkflowStep{{
|
||||
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
|
||||
Critic: &core.StepCritic{Criteria: "要过", MaxRetries: 1},
|
||||
}},
|
||||
}},
|
||||
f := &wfFake{settings: core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
|
||||
"baseUrl": srv.URL, "apiKey": "test-key", "model": "MiniMax-test",
|
||||
}}}}
|
||||
stream := judgeStreamFor(f)
|
||||
if stream == nil {
|
||||
t.Fatal("已配置轻量模型时必须给出验收流")
|
||||
}
|
||||
if err := (WorkflowRunner{}).Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
for ev := range stream(context.Background(), "verify", []agentllm.Message{{Role: agentllm.RoleUser, Content: "x"}}, nil, agentllm.StreamOptions{}) {
|
||||
if ev.Kind == agentllm.KindError {
|
||||
t.Fatal(ev.Text)
|
||||
}
|
||||
}
|
||||
if got != "MiniMax-test" {
|
||||
t.Fatalf("model %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannedWorkflowNeedsConfirm(t *testing.T) {
|
||||
plan := `{"id":"plan.brief","title":"简报","steps":[{"id":"ask","title":"确认","kind":"hitl","hitl":{"question":"可以吗?","kind":"confirm","allowFreeInput":false}}]}`
|
||||
f := &wfFake{
|
||||
spawnText: plan,
|
||||
hitl: []core.HITLResponse{{ChoiceID: "yes"}, {ChoiceID: "ok"}},
|
||||
}
|
||||
st := &directRun{rc: f}
|
||||
out := st.runRegisteredWorkflow(`{"goal":"写一份简报"}`)
|
||||
if f.spawnIDs[0] != "agent.sub.workflow-plan" || !strings.Contains(f.hitlQ[0], "按这个计划执行") {
|
||||
t.Fatalf("spawn %v questions %v", f.spawnIDs, f.hitlQ)
|
||||
}
|
||||
if !strings.Contains(out, "完成") || f.hitlQ[1] != "可以吗?" {
|
||||
t.Fatalf("out %s questions %v", out, f.hitlQ)
|
||||
}
|
||||
|
||||
f2 := &wfFake{spawnText: plan, hitl: []core.HITLResponse{{ChoiceID: "no"}}}
|
||||
out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"goal":"写一份简报"}`)
|
||||
if out != "未确认,未执行" || len(f2.hitlQ) != 1 {
|
||||
t.Fatalf("out %s questions %v", out, f2.hitlQ)
|
||||
}
|
||||
|
||||
var steps []string
|
||||
for i := 0; i < 9; i++ {
|
||||
steps = append(steps, fmt.Sprintf(`{"id":"s%d","title":"步","kind":"hitl"}`, i))
|
||||
}
|
||||
f3 := &wfFake{spawnText: `{"id":"too.big","title":"太多","steps":[` + strings.Join(steps, ",") + `]}`}
|
||||
out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"goal":"做很多事"}`)
|
||||
if !strings.Contains(out, "超出上限") || len(f3.hitlQ) != 0 {
|
||||
t.Fatalf("out %s questions %v", out, f3.hitlQ)
|
||||
}
|
||||
|
||||
f4 := &wfFake{spawnText: `{"id":"bad.script","title":"脚本","steps":[{"id":"run","title":"跑","kind":"script","script":"a.py"}]}`}
|
||||
out = (&directRun{rc: f4}).runRegisteredWorkflow(`{"goal":"跑脚本"}`)
|
||||
if !strings.Contains(out, "script") || len(f4.hitlQ) != 0 {
|
||||
t.Fatalf("out %s questions %v", out, f4.hitlQ)
|
||||
if judgeStreamFor(&wfFake{}) != nil {
|
||||
t.Fatal("未配置模型应为 nil(验证器不可用 fail-closed)")
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/codeorch"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const (
|
||||
scriptDefaultTimeout = 600 * time.Second
|
||||
scriptMaxTimeout = 1800 * time.Second
|
||||
scriptMaxCalls = 50
|
||||
scriptMaxLine = 1 << 20
|
||||
)
|
||||
|
||||
func scriptsEnabled(rc RunCtx) bool {
|
||||
return rc.Settings().GetBool("runtime.workflowScripts", false)
|
||||
}
|
||||
|
||||
func scriptSandbox(rc RunCtx) (string, error) {
|
||||
sandbox := strings.TrimSpace(conversationSandbox(rc))
|
||||
if sandbox == "" {
|
||||
return "", fmt.Errorf("workflow: 没有会话沙箱")
|
||||
}
|
||||
return sandbox, nil
|
||||
}
|
||||
|
||||
func executeScriptStep(rc RunCtx, runner WorkflowRunner, step core.WorkflowStep, scripts map[string]string) (string, []*core.ArtifactRef, error) {
|
||||
body := scripts[step.Script]
|
||||
if strings.TrimSpace(body) == "" {
|
||||
var err error
|
||||
body, err = scriptBody(rc, step.Script)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
sandbox, err := scriptSandbox(rc)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
path, err := writeScriptFile(sandbox, "step-"+step.ID, body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return runScriptFile(rc, runner, path)
|
||||
}
|
||||
|
||||
func scriptBody(rc RunCtx, path string) (string, error) {
|
||||
src, ok := rc.(interface {
|
||||
Workflow() (workflow.Launch, bool)
|
||||
})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("workflow: 脚本 %s 不在启动副本里", path)
|
||||
}
|
||||
launch, ok := src.Workflow()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("workflow: 脚本 %s 不在启动副本里", path)
|
||||
}
|
||||
body, ok := launch.Scripts[path]
|
||||
if !ok || strings.TrimSpace(body) == "" {
|
||||
return "", fmt.Errorf("workflow: 脚本 %s 不在启动副本里", path)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func registeredWorkflowLaunch(rc RunCtx, def core.WorkflowDef, slots map[string]string) workflow.Launch {
|
||||
launch := workflow.Launch{Def: def, Slots: slots, Scripts: map[string]string{}}
|
||||
lookup, ok := rc.(interface {
|
||||
WorkflowScript(id, path string) (string, bool)
|
||||
})
|
||||
if !ok {
|
||||
return launch
|
||||
}
|
||||
for _, step := range def.Steps {
|
||||
if step.Kind != "script" || strings.TrimSpace(step.Script) == "" {
|
||||
continue
|
||||
}
|
||||
if body, found := lookup.WorkflowScript(def.ID, step.Script); found {
|
||||
launch.Scripts[step.Script] = body
|
||||
}
|
||||
}
|
||||
return launch
|
||||
}
|
||||
|
||||
func runModelScript(rc RunCtx, runner WorkflowRunner, body string) (string, []*core.ArtifactRef, error) {
|
||||
if !scriptsEnabled(rc) {
|
||||
return "", nil, fmt.Errorf("workflow: 工作流脚本未开启")
|
||||
}
|
||||
preview := body
|
||||
if len(preview) > 4000 {
|
||||
preview = preview[:4000] + "\n…(脚本超长,已截断展示)"
|
||||
}
|
||||
resp, err := rc.AskHITL(core.HITLCard{
|
||||
Question: "运行这段脚本?\n" + preview, Kind: core.HITLKindConfirm, AllowFreeInput: false,
|
||||
Choices: []core.HITLChoice{
|
||||
{ID: "yes", Label: "运行", Description: "在会话沙箱里执行", Impact: "会调用工具并可能写入工件"},
|
||||
{ID: "no", Label: "取消", Description: "不执行", Impact: "不会改动任何东西"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.ChoiceID != "yes" {
|
||||
return "未确认,未执行", nil, nil
|
||||
}
|
||||
sandbox, err := scriptSandbox(rc)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
path, err := writeScriptFile(sandbox, "model", body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return runScriptFile(rc, runner, path)
|
||||
}
|
||||
|
||||
func writeScriptFile(sandbox, prefix, body string) (string, error) {
|
||||
dir := filepath.Join(sandbox, ".agent")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("wf-%s-%d.py", prefix, time.Now().UnixNano()))
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func runScriptFile(rc RunCtx, runner WorkflowRunner, path string) (string, []*core.ArtifactRef, error) {
|
||||
if !scriptsEnabled(rc) {
|
||||
return "", nil, fmt.Errorf("workflow: 工作流脚本未开启")
|
||||
}
|
||||
sandbox, err := scriptSandbox(rc)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
timeout := runner.ScriptTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = scriptDefaultTimeout
|
||||
}
|
||||
parent := rc.Context()
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
var arts []*core.ArtifactRef
|
||||
out := codeorch.RunFile(parent, sandbox, path, codeorch.Limits{
|
||||
Timeout: timeout,
|
||||
MaxTimeout: scriptMaxTimeout,
|
||||
MaxCalls: scriptMaxCalls,
|
||||
MaxResultBytes: scriptMaxLine,
|
||||
MaxModelChars: 16000,
|
||||
FullDir: sandbox,
|
||||
}, func(ctx context.Context, dir, scriptPath string) (*exec.Cmd, error) {
|
||||
return tools.ScriptCommand(ctx, dir, scriptPath)
|
||||
}, func(_ context.Context, call codeorch.Call) (string, error) {
|
||||
output, got, err := dispatchScript(rc, call)
|
||||
arts = append(arts, got...)
|
||||
return output, err
|
||||
})
|
||||
return out.ModelText(), arts, out.Err
|
||||
}
|
||||
|
||||
func dispatchScript(rc RunCtx, req codeorch.Call) (string, []*core.ArtifactRef, error) {
|
||||
var choices []core.HITLChoice
|
||||
if len(req.Choices) > 0 {
|
||||
_ = json.Unmarshal(req.Choices, &choices)
|
||||
}
|
||||
switch req.Op {
|
||||
case "tool.call":
|
||||
if _, denied := workflow.DeniedTools[req.Name]; denied {
|
||||
return "", nil, fmt.Errorf("不能调用 %s", req.Name)
|
||||
}
|
||||
res := rc.ExecuteTool(core.ToolCallRequest{Name: req.Name, Arguments: req.Args})
|
||||
if res.Status == "error" {
|
||||
return "", nil, fmt.Errorf("%s", res.Output)
|
||||
}
|
||||
var arts []*core.ArtifactRef
|
||||
if res.Artifact != nil {
|
||||
arts = append(arts, &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind})
|
||||
}
|
||||
arts = append(arts, artifactsIn(rc, res.Output)...)
|
||||
return res.Output, arts, nil
|
||||
case "hitl.ask":
|
||||
card := core.HITLCard{Question: req.Question, Kind: core.HITLKindInput, AllowFreeInput: true}
|
||||
if len(choices) > 0 {
|
||||
card.Kind = core.HITLKindChoice
|
||||
card.AllowFreeInput = false
|
||||
card.Choices = choices
|
||||
for i := range card.Choices {
|
||||
if card.Choices[i].Impact == "" {
|
||||
card.Choices[i].Impact = "会按这个选择继续"
|
||||
}
|
||||
}
|
||||
}
|
||||
resp, err := rc.AskHITL(card)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if strings.TrimSpace(resp.FreeText) != "" {
|
||||
return strings.TrimSpace(resp.FreeText), nil, nil
|
||||
}
|
||||
return resp.ChoiceID, nil, nil
|
||||
case "agent.spawn":
|
||||
agent := req.Agent
|
||||
if strings.TrimSpace(agent) == "" {
|
||||
agent = "general"
|
||||
}
|
||||
var res core.AgentResultPayload
|
||||
var err error
|
||||
if sp, ok := rc.(interface {
|
||||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
}); ok {
|
||||
res, err = sp.SpawnAgent(subagentProfileID(agent), req.Title, req.Task, "")
|
||||
} else {
|
||||
res, err = rc.SpawnSubagent(req.Title, req.Task, "")
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
text := res.Text
|
||||
if text == "" {
|
||||
text = res.Summary
|
||||
}
|
||||
return text, artifactsIn(rc, text), nil
|
||||
case "artifact.register":
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = "script.md"
|
||||
}
|
||||
art, err := rc.WriteArtifact(name, "document", "text/markdown", []byte(req.Content))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return string(art.ID), []*core.ArtifactRef{{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}, nil
|
||||
case "card.create":
|
||||
kind := core.CardKind(req.Kind)
|
||||
if kind == "" {
|
||||
kind = core.CardKindResult
|
||||
}
|
||||
title := req.Title
|
||||
if title == "" {
|
||||
title = req.Name
|
||||
}
|
||||
created, err := rc.CreateCard(core.ActivityCard{Title: title, Kind: kind})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return string(created.ID), nil, nil
|
||||
case "step":
|
||||
applyScriptPlan(rc, req.StepID, req.Title, req.Status)
|
||||
return req.Status, nil, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("未知回调 %s", req.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func applyScriptPlan(rc RunCtx, id, title, status string) {
|
||||
switch status {
|
||||
case "pending", "in_progress", "done", "skipped":
|
||||
default:
|
||||
status = "done"
|
||||
}
|
||||
snap := core.PlanSnapshot{}
|
||||
if src, ok := rc.(interface {
|
||||
Plan() (core.PlanSnapshot, bool)
|
||||
}); ok {
|
||||
if cur, found := src.Plan(); found {
|
||||
snap = cur
|
||||
snap.Steps = append([]core.PlanStep(nil), cur.Steps...)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for i := range snap.Steps {
|
||||
if snap.Steps[i].ID == id {
|
||||
snap.Steps[i].Status = status
|
||||
if title != "" {
|
||||
snap.Steps[i].Text = title
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
snap.Steps = append(snap.Steps, core.PlanStep{ID: id, Text: title, Status: status})
|
||||
}
|
||||
_ = rc.UpdatePlan(snap)
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func scriptSettings() core.EffectiveSettings {
|
||||
return core.EffectiveSettings{"runtime": map[string]any{"workflowScripts": true}}
|
||||
}
|
||||
|
||||
func TestScriptStepRunsEachCallback(t *testing.T) {
|
||||
body := `import agentwf as wf
|
||||
print("tool", wf.tool("time_now", {}))
|
||||
print("ask", wf.ask("继续?", [{"id":"yes","label":"好","impact":"继续"}]))
|
||||
print("agent", wf.agent("research", "写一句"))
|
||||
print("art", wf.artifact("note.md", "你好"))
|
||||
print("card", wf.card("结果", "result"))
|
||||
print("step", wf.step("s1", "一步", "done"))
|
||||
print("file", wf.tool("make_file", {}))
|
||||
`
|
||||
f := &wfFake{
|
||||
sandbox: t.TempDir(),
|
||||
settings: scriptSettings(),
|
||||
hitl: []core.HITLResponse{{ChoiceID: "yes"}},
|
||||
launch: workflow.Launch{
|
||||
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{
|
||||
{ID: "run", Title: "跑", Kind: "script", Script: "workflows/scripts/demo.py"},
|
||||
{ID: "save", Title: "留下", Kind: "artifact", Prompt: "{{steps.run.output}}"},
|
||||
}},
|
||||
Scripts: map[string]string{"workflows/scripts/demo.py": body},
|
||||
},
|
||||
}
|
||||
if err := (WorkflowRunner{}).Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := f.writes[len(f.writes)-1]
|
||||
for _, want := range []string{"tool 12:00", "ask yes", "agent 正文", "art art_aaaaaaaaaaaa", "card card_1", "file wrote"} {
|
||||
if !strings.Contains(saved, want) {
|
||||
t.Fatalf("missing %q in %s", want, saved)
|
||||
}
|
||||
}
|
||||
if len(f.tools) != 2 || f.tools[0].Name != "time_now" || f.tools[1].Name != "make_file" {
|
||||
t.Fatalf("tools %+v", f.tools)
|
||||
}
|
||||
if len(f.cards) != 1 || f.cards[0].Title != "结果" {
|
||||
t.Fatalf("cards %+v", f.cards)
|
||||
}
|
||||
if len(f.spawnIDs) != 1 || f.spawnIDs[0] != "agent.sub.research" {
|
||||
t.Fatalf("spawn %v", f.spawnIDs)
|
||||
}
|
||||
if f.writes[0] != "note.md=你好" {
|
||||
t.Fatalf("writes %v", f.writes)
|
||||
}
|
||||
sawStep := false
|
||||
var ids []string
|
||||
for _, plan := range f.plans {
|
||||
for _, step := range plan.Steps {
|
||||
if step.ID == "s1" && step.Status == "done" {
|
||||
sawStep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, part := range f.assistant {
|
||||
if part.Artifact != nil {
|
||||
ids = append(ids, string(part.Artifact.ArtifactID))
|
||||
}
|
||||
}
|
||||
if !sawStep {
|
||||
t.Fatalf("plans %+v", f.plans)
|
||||
}
|
||||
joined := strings.Join(ids, " ")
|
||||
if !strings.Contains(joined, "art_aaaaaaaaaaaa") || !strings.Contains(joined, "art_bbbbbbbbbbbb") {
|
||||
t.Fatalf("deliverables %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptRejectsDeniedTool(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `import agentwf as wf
|
||||
try:
|
||||
wf.tool("run_python", {})
|
||||
except Exception as exc:
|
||||
open("ERR", "w").write(str(exc))
|
||||
`
|
||||
f := &wfFake{
|
||||
sandbox: dir,
|
||||
settings: scriptSettings(),
|
||||
launch: workflow.Launch{
|
||||
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
|
||||
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
|
||||
}}},
|
||||
Scripts: map[string]string{"a.py": body},
|
||||
},
|
||||
}
|
||||
if err := (WorkflowRunner{}).Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "ERR"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "不能调用 run_python") || len(f.tools) != 0 {
|
||||
t.Fatalf("err %s tools %+v", raw, f.tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptStopsAfterCallCap(t *testing.T) {
|
||||
body := `import agentwf as wf
|
||||
for i in range(60):
|
||||
wf.tool("time_now", {})
|
||||
`
|
||||
f := &wfFake{
|
||||
sandbox: t.TempDir(),
|
||||
settings: scriptSettings(),
|
||||
launch: workflow.Launch{
|
||||
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
|
||||
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
|
||||
}}},
|
||||
Scripts: map[string]string{"a.py": body},
|
||||
},
|
||||
}
|
||||
err := (WorkflowRunner{}).Execute(f)
|
||||
if err == nil || !strings.Contains(err.Error(), "超过 50") || len(f.tools) != 50 {
|
||||
t.Fatalf("err %v calls %d", err, len(f.tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptCancelKillsProcess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `import os, time
|
||||
open("PID", "w").write(str(os.getpid()))
|
||||
time.sleep(30)
|
||||
open("AFTER", "w").write("ran")
|
||||
`
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &wfFake{
|
||||
ctx: ctx,
|
||||
sandbox: dir,
|
||||
settings: scriptSettings(),
|
||||
launch: workflow.Launch{
|
||||
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
|
||||
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
|
||||
}}},
|
||||
Scripts: map[string]string{"a.py": body},
|
||||
},
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- (WorkflowRunner{}).Execute(f) }()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "PID")); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
cancel()
|
||||
t.Fatal("script did not start")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "PID"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err = <-done:
|
||||
if err == nil || !strings.Contains(err.Error(), "取消") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("cancel did not stop the script")
|
||||
}
|
||||
if _, err := os.Stat("/proc/" + strings.TrimSpace(string(raw))); err == nil {
|
||||
t.Fatal("script process still alive")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "AFTER")); err == nil {
|
||||
t.Fatal("script kept running after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptLineLimit(t *testing.T) {
|
||||
body := `import os
|
||||
f = os.fdopen(3, "w", encoding="utf-8", buffering=1)
|
||||
f.write("x" * (1024 * 1024 + 32) + "\n")
|
||||
f.flush()
|
||||
`
|
||||
f := &wfFake{
|
||||
sandbox: t.TempDir(),
|
||||
settings: scriptSettings(),
|
||||
launch: workflow.Launch{
|
||||
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
|
||||
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
|
||||
}}},
|
||||
Scripts: map[string]string{"a.py": body},
|
||||
},
|
||||
}
|
||||
err := (WorkflowRunner{}).Execute(f)
|
||||
if err == nil || !strings.Contains(err.Error(), "1MB") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredWorkflowUsesItsOwnScriptCopy(t *testing.T) {
|
||||
f := &wfFake{sandbox: t.TempDir(), settings: scriptSettings()}
|
||||
res, err := (WorkflowRunner{}).run(f, workflow.Launch{
|
||||
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
|
||||
ID: "run", Title: "跑", Kind: "script", Script: "workflows/scripts/demo.py",
|
||||
}}},
|
||||
Scripts: map[string]string{"workflows/scripts/demo.py": "print('from-launch')\n"},
|
||||
})
|
||||
if err != nil || !strings.Contains(res.Summary, "完成") {
|
||||
t.Fatalf("err %v summary %s", err, res.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelScriptNeedsSwitchAndConfirm(t *testing.T) {
|
||||
f := &wfFake{sandbox: t.TempDir()}
|
||||
out := (&directRun{rc: f}).runRegisteredWorkflow(`{"script":"print('pong')"}`)
|
||||
if !strings.Contains(out, "未开启") || len(f.hitlQ) != 0 {
|
||||
t.Fatalf("%s questions %v", out, f.hitlQ)
|
||||
}
|
||||
|
||||
f2 := &wfFake{sandbox: t.TempDir(), settings: scriptSettings(), hitl: []core.HITLResponse{{ChoiceID: "no"}}}
|
||||
out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"script":"print('pong')"}`)
|
||||
if out != "未确认,未执行" || !strings.Contains(f2.hitlQ[0], "print('pong')") {
|
||||
t.Fatalf("%s questions %v", out, f2.hitlQ)
|
||||
}
|
||||
|
||||
f3 := &wfFake{sandbox: t.TempDir(), settings: scriptSettings(), hitl: []core.HITLResponse{{ChoiceID: "yes"}}}
|
||||
out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"script":"print('pong')"}`)
|
||||
if !strings.Contains(out, "pong") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// wp5_ab_test.go 是 N1 WP5 验收的 A/B 对照:门禁机制下沉后,
|
||||
// 同一组脚本化模型输出的停止原因、提醒次数与工具拒绝必须与记录的
|
||||
// 旧行为逐条一致。既有套件(GuardTwiceThenNotice / VerifierRejectsThenPasses /
|
||||
// VerifierGarbageFailsClosed / TodoReminder 三测)在实现切换后原样通过,
|
||||
// 是性格化等价的主网;本文件补齐两个此前无覆盖的验收角度。
|
||||
|
||||
// TestVerifyCapNoticeAfterThreeContinuations:验证器持续拒绝时,
|
||||
// 续跑恰为 3 次(gates.MaxVerifyContinuations),随后以 verifyCapNotice 收尾,
|
||||
// 理由逐次进入过程区。
|
||||
func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) {
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("答案 v1。"),
|
||||
textDeltaTurn("答案 v2。"),
|
||||
textDeltaTurn("答案 v3。"),
|
||||
textDeltaTurn("答案 v4。"),
|
||||
}
|
||||
var mainCalls int
|
||||
base := scriptStream(turns, nil)
|
||||
r := &LLMRunner{
|
||||
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
mainCalls++
|
||||
return base(ctx, model, msgs, tools, opts)
|
||||
},
|
||||
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
return passedStream(`{"passed":false,"reason":"来源未标注","nextAction":"补上出处"}`)(ctx, model, msgs, tools, opts)
|
||||
},
|
||||
}
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if mainCalls != 4 {
|
||||
t.Fatalf("main calls = %d, want 4(3 次续跑 + 收尾)", mainCalls)
|
||||
}
|
||||
texts := textParts(rc.assistant)
|
||||
last := texts[len(texts)-1]
|
||||
if last.Process || !strings.Contains(last.Text, "本轮回复未通过终轮检查") {
|
||||
t.Fatalf("final notice = %+v, want non-process verify cap notice", last)
|
||||
}
|
||||
// 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。
|
||||
notes := 0
|
||||
for _, p := range rc.assistant {
|
||||
if p.Type == core.PartCheck && p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "来源未标注") {
|
||||
notes++
|
||||
}
|
||||
}
|
||||
if notes != 4 {
|
||||
t.Fatalf("verify notes = %d, want 4(3 次续跑 + 1 次封顶); checks = %+v", notes, rc.assistant)
|
||||
}
|
||||
// 前三个被降级的回复进了过程区;最后一次回复与收尾 Notice 留在正式区。
|
||||
demoted := 0
|
||||
for _, p := range texts {
|
||||
if p.Process && strings.HasPrefix(p.Text, "答案 v") {
|
||||
demoted++
|
||||
}
|
||||
}
|
||||
if demoted != 3 {
|
||||
t.Fatalf("demoted answers = %d, want 3; parts = %+v", demoted, texts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGateReasonTextParityAfterSink:下沉后门禁分支理由逐字一致——
|
||||
// verify 开启时,声称交付无工件的理由必须原样进入过程区。
|
||||
func TestGateReasonTextParityAfterSink(t *testing.T) {
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("文档已生成:甲.docx"),
|
||||
textDeltaTurn("文档已保存:乙.docx"),
|
||||
textDeltaTurn("文档已导出:丙.docx"),
|
||||
}
|
||||
r := &LLMRunner{Stream: scriptStream(turns, nil)}
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !hasProcessNote(rc.assistant, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件") {
|
||||
t.Fatalf("gate reason missing after sink: %+v", textParts(rc.assistant))
|
||||
}
|
||||
texts := textParts(rc.assistant)
|
||||
last := texts[len(texts)-1]
|
||||
if last.Process || !strings.Contains(last.Text, "本轮未实际生成文件") {
|
||||
t.Fatalf("final notice = %+v", last)
|
||||
}
|
||||
// 伪造调用标记的分支理由也要逐字一致。
|
||||
r2 := &LLMRunner{Stream: scriptStream([][]agentllm.Event{
|
||||
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
|
||||
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
|
||||
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
|
||||
}, nil)}
|
||||
rc2 := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
|
||||
if err := r2.Execute(rc2); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !hasProcessNote(rc2.assistant, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用") {
|
||||
t.Fatalf("forged marker reason missing after sink: %+v", textParts(rc2.assistant))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
// 命令审计按租户作用域(回归:GET /api/commands/audit 曾返回全租户审计行——
|
||||
// ListAudits 无 owner 条件、command_audit 无 owner 列;多用户档下任何登录
|
||||
// 用户可读他人命令 ID、入口、会话 ID 与参数摘要)。
|
||||
// 经真实认证中间件(API key → owner)请求,断言每个租户只见本租户的行。
|
||||
func TestCommandAuditOwnerScoping(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
aliceKey, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobKey, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 两租户各产生一条命令审计(owner 列=v18 起的归属依据;Actor 仅为信封内容)。
|
||||
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{
|
||||
CommandID: "cmd.alice", Actor: "alice", State: agentcommand.StateCommitted,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AppendAudit("bob", agentcommand.AuditEnvelope{
|
||||
CommandID: "cmd.bob", Actor: "bob", State: agentcommand.StateCommitted,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
get := func(key string) []map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/commands/audit", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("audit query: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Audits []map[string]any `json:"audits"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Audits
|
||||
}
|
||||
|
||||
for key, owner := range map[string]string{aliceKey: "alice", bobKey: "bob"} {
|
||||
rows := get(key)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("owner %s: audit rows = %d, want 1(跨租户行不得返回;got=%v)", owner, len(rows), rows)
|
||||
}
|
||||
if rows[0]["actor"] != owner || rows[0]["commandId"] != "cmd."+owner {
|
||||
t.Fatalf("owner %s: wrong row %v", owner, rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ type pair struct {
|
||||
|
||||
type nopAudit struct{}
|
||||
|
||||
func (nopAudit) AppendAudit(agentcommand.AuditEnvelope) error { return nil }
|
||||
func (nopAudit) AppendAudit(string, agentcommand.AuditEnvelope) error { return nil }
|
||||
|
||||
// 每个内置命令都必须在 command-pairs.json 里同时声明按钮入口与对话入口。
|
||||
// 新命令漏登记则本测试失败,合入被挡住。
|
||||
|
||||
+154
-10
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
@@ -15,17 +16,23 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
osexec "os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
nativetools "agent-native/runtime/tools"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
skillseed "agent-system-backend/internal/skills"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
@@ -65,6 +72,62 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
return srv, st
|
||||
}
|
||||
|
||||
// stubNativeStream 内核桩模型流(零网络):首轮按任务书「期望产出」逐个
|
||||
// write_file 落盘(agent-native 契约:workspace 档步骤自己写交付物),
|
||||
// 见到工具结果后给收尾文本。
|
||||
func stubNativeStream(text string) agentllm.StreamFunc {
|
||||
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 8)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
finish := func() {
|
||||
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
|
||||
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
|
||||
Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: text}}
|
||||
}
|
||||
prompt := ""
|
||||
for _, m := range msgs {
|
||||
if m.Role == agentllm.RoleTool {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if m.Role == agentllm.RoleUser {
|
||||
prompt = m.Content
|
||||
}
|
||||
}
|
||||
targets := stubTargets(prompt)
|
||||
if len(targets) == 0 {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
var calls []agentllm.ToolCall
|
||||
for i, tgt := range targets {
|
||||
args := fmt.Sprintf(`{"path":%q,"content":%q}`, tgt, text)
|
||||
id := fmt.Sprintf("call_stub_%d", i+1)
|
||||
ch <- agentllm.Event{Kind: agentllm.KindToolCallDelta, ToolIndex: i,
|
||||
ToolID: id, ToolName: "write_file", ArgumentsDelta: args}
|
||||
calls = append(calls, agentllm.ToolCall{ID: id, Name: "write_file", Arguments: args})
|
||||
}
|
||||
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
|
||||
Message: agentllm.Message{Role: agentllm.RoleAssistant, ToolCalls: calls, StopReason: agentllm.StopToolCalls}}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
}
|
||||
|
||||
// stubTargets 从任务书解析「期望产出:[a.md b.md]」。
|
||||
func stubTargets(prompt string) []string {
|
||||
i := strings.Index(prompt, "期望产出:[")
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
j := strings.Index(prompt[i:], "]")
|
||||
if j < 0 {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(prompt[i+len("期望产出:[") : i+j])
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
@@ -853,9 +916,22 @@ func TestHITLIntentPivot(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSquadOrchestration:Agent-Squad 引擎真实驱动——规则规划器四阶段计划、
|
||||
// 每步经 "agent-system" 后端派生子代理(任务树)、计划事件、交付物工件。
|
||||
// Q3 WP2 后:每步经 "agent-native" 内核后端在进程内执行(桩 Stream,零网络;
|
||||
// SpawnSubagent 压缩路径已退役——步骤不再派生任务树子 Run)、计划事件、交付物工件。
|
||||
func TestSquadOrchestration(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
skillsRoot := t.TempDir()
|
||||
if _, serr := skillseed.Seed(skillsRoot); serr != nil {
|
||||
t.Fatal(serr)
|
||||
}
|
||||
savedNative := squadadapter.AgentNativeOptions
|
||||
squadadapter.AgentNativeOptions = func(squadadapter.Ctx) (nativeadp.Config, bool) {
|
||||
py, _ := osexec.LookPath("python3")
|
||||
return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot},
|
||||
Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubNativeStream(
|
||||
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
|
||||
}
|
||||
defer func() { squadadapter.AgentNativeOptions = savedNative }()
|
||||
h := srv.Handler()
|
||||
projectID, convID := setupConversation(t, h)
|
||||
runID := postMessage(t, h, convID, "#squad 生成一份示例目标简报")
|
||||
@@ -889,17 +965,12 @@ func TestSquadOrchestration(t *testing.T) {
|
||||
t.Fatalf("squad run status: %v (err: %v)", terminal["status"], terminal["error"])
|
||||
}
|
||||
|
||||
// 1) 任务树:squad 步骤全部成为子代理 run。
|
||||
// 1) 任务树:内核后端进程内执行步骤,不再派生子代理 run
|
||||
//(SpawnSubagent 压缩路径退役的行为面)。
|
||||
_, out := doJSON(t, h, "GET", "/api/runs/"+runID, nil)
|
||||
children := out["children"].([]any)
|
||||
if len(children) < 3 {
|
||||
t.Fatalf("expected >=3 squad step subagents, got %d", len(children))
|
||||
}
|
||||
for _, ci := range children {
|
||||
c := ci.(map[string]any)
|
||||
if c["kind"] != "subagent" || c["parentRunId"] != runID {
|
||||
t.Errorf("bad squad child: %+v", c)
|
||||
}
|
||||
if len(children) != 0 {
|
||||
t.Fatalf("kernel steps must not spawn subagent runs, got %d children", len(children))
|
||||
}
|
||||
|
||||
// 2) 计划快照:四阶段骨架步骤映射到右栏计划。
|
||||
@@ -1199,3 +1270,76 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
|
||||
t.Fatalf("apiKey mask = %v", chat["apiKey"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadXlsxParsedAndIndexed:xlsx 上传走内置解析器(builtin:xlsx、无警告),
|
||||
// 单元格文本进入知识检索(EXT-5 验收路径)。
|
||||
func TestUploadXlsxParsedAndIndexed(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
projectID, _ := setupConversation(t, h)
|
||||
|
||||
xlsx := buildTestXLSX(t)
|
||||
body := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(body)
|
||||
fw, _ := mw.CreateFormFile("file", "报价单.xlsx")
|
||||
fw.Write(xlsx)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
up := map[string]any{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &up)
|
||||
parsed, _ := up["parsed"].(map[string]any)
|
||||
if parsed == nil {
|
||||
t.Fatalf("upload response missing parsed: %s", rec.Body.String())
|
||||
}
|
||||
meta, _ := parsed["meta"].(map[string]any)
|
||||
if meta["parser"] != "builtin:xlsx" {
|
||||
t.Fatalf("parser: %v", meta["parser"])
|
||||
}
|
||||
if w, ok := parsed["warnings"].([]any); ok && len(w) > 0 {
|
||||
t.Fatalf("xlsx upload should parse without warnings: %v", w)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
code, raw := doAny(t, h, "GET", "/api/knowledge/search?q=%E6%94%AF%E6%9E%B6", nil)
|
||||
if code == 200 {
|
||||
if hits, ok := raw.([]any); ok && len(hits) > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("knowledge search found nothing for xlsx cell text")
|
||||
}
|
||||
|
||||
// buildTestXLSX 构造最小可用 xlsx(inlineStr 单元格)。
|
||||
func buildTestXLSX(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></row>
|
||||
<row r="2"><c r="A2" t="inlineStr"><is><t>支架</t></is></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,102 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// EXT-9 验收:DisableUploadIndexing=true 时上传不再自动进检索(原件与摄取卡
|
||||
// 行为不变);=false 恢复现状(可解析文本入索引)。
|
||||
func TestUploadPreIndexSwitch(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON(t, map[string]string{"name": "EXT9"})))
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create project: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var pj map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &pj)
|
||||
pid := fmt.Sprint(pj["id"])
|
||||
|
||||
upload := func(name, text string) string {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("file", name)
|
||||
fw.Write([]byte(text))
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf)
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload %s: %d %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
var up map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &up)
|
||||
return fmt.Sprint(up["artifactId"])
|
||||
}
|
||||
search := func(q string) int {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q="+q, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var arr []any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &arr); err == nil {
|
||||
return len(arr) // 直接数组形态
|
||||
}
|
||||
var out struct {
|
||||
Hits []any `json:"hits"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("search decode: %v (%s)", err, rec.Body.String())
|
||||
}
|
||||
return len(out.Hits)
|
||||
}
|
||||
|
||||
// 开关开:上传不入检索,但原件完整可取回。
|
||||
srv.DisableUploadIndexing = true
|
||||
aid := upload("ext9-off.txt", "EXT9OFF 索引开关独占词甲")
|
||||
if n := search("索引开关独占词甲"); n != 0 {
|
||||
t.Fatalf("switch on: search hits=%d, want 0(存疑确认前不入正式检索)", n)
|
||||
}
|
||||
reqC := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+aid+"/content", nil)
|
||||
reqC.Header.Set("Authorization", "Bearer "+alice)
|
||||
recC := httptest.NewRecorder()
|
||||
h.ServeHTTP(recC, reqC)
|
||||
if recC.Code != http.StatusOK || !bytes.Contains(recC.Body.Bytes(), []byte("索引开关独占词甲")) {
|
||||
t.Fatalf("original must remain retrievable: %d %q", recC.Code, recC.Body.String())
|
||||
}
|
||||
|
||||
// 开关关(现状):同一上传路径恢复自动索引。
|
||||
srv.DisableUploadIndexing = false
|
||||
upload("ext9-on.txt", "EXT9ON 索引开关独占词乙")
|
||||
if n := search("索引开关独占词乙"); n != 1 {
|
||||
t.Fatalf("switch off: search hits=%d, want 1(现状自动索引)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EXT-8 资源授权适配器(红测:基础设施已就位、读取入口尚未咨询)——
|
||||
// 默认拒绝保持现状;注册适配器后同项目成员可读共享资源、跨项目仍拒。
|
||||
func TestProjectResourceAuthorizerSharesReads(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
carol, err := st.CreateAPIKey("carol", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// alice 建项目并上传一份工件(owner=alice)。
|
||||
prj := struct {
|
||||
Name string `json:"name"`
|
||||
}{Name: "EXT8 共享项目"}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(marshal(t, prj)))
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create project: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
pid := decodeField(t, rec.Body.Bytes(), "id")
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("file", "共享说明.txt")
|
||||
fw.Write([]byte("SHARED-CONTENT"))
|
||||
mw.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf)
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
aid := decodeField(t, rec.Body.Bytes(), "artifactId")
|
||||
|
||||
get := func(key, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// 默认拒绝(未注册适配器):bob 任何读取都是 NotFound,不泄露存在性。
|
||||
for _, path := range []string{"/api/projects/" + pid, "/api/projects/" + pid + "/artifacts", "/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} {
|
||||
if rec := get(bob, path); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("default-deny: %s = %d, want 404", path, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册适配器:bob 对该项目有共享授权;carol 无。
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "bob" && string(projectID) == pid, nil
|
||||
})
|
||||
|
||||
if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusOK {
|
||||
t.Fatalf("shared artifact meta: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = get(bob, "/api/artifacts/"+aid+"/content")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "SHARED-CONTENT" {
|
||||
t.Fatalf("shared artifact content: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = get(bob, "/api/projects/"+pid+"/artifacts")
|
||||
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(aid)) {
|
||||
t.Fatalf("shared artifact list: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 跨项目/未授权用户与错误路径仍拒(NotFound;错误回调 fail-closed)。
|
||||
for _, path := range []string{"/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} {
|
||||
if rec := get(carol, path); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("carol must stay denied: %s = %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return false, io.ErrClosedPipe })
|
||||
if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("authorizer error must fail closed: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func marshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func decodeField(t *testing.T, body []byte, field string) string {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("decode %s: %v (%s)", field, err, body)
|
||||
}
|
||||
return fmt.Sprint(m[field])
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- N3 WP3:rewind HTTP 层(happy + busy 409)----
|
||||
|
||||
// happy:经 API 回滚被追踪写入,restored/skipped 上抛,文件内容退回。
|
||||
func TestRewindFilesHTTPHappy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, &runtime.MockRunner{}, &runtime.MockSubagent{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
ws := t.TempDir()
|
||||
mgr.SetWorkspaceRoot(ws)
|
||||
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Bus = cmdbus.New(st, nil, nil)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
runID := postMessage(t, h, convID, "写个文件")
|
||||
// 等 run 终态(mock run 是异步的;不回等会撞到会话占用 409)。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
run, err := st.GetRunByID(runID)
|
||||
if err == nil && (run.Status == core.RunCompleted || run.Status == core.RunFailed || run.Status == core.RunCancelled) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("run 未终态:%+v", run)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 找到用户消息(anchor)并造一条被追踪写入。
|
||||
var userMsgID string
|
||||
msgs, err := st.ListMessages(convID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.Role == core.RoleUser {
|
||||
userMsgID = m.ID
|
||||
}
|
||||
}
|
||||
if userMsgID == "" {
|
||||
t.Fatal("no user message")
|
||||
}
|
||||
sandbox := filepath.Join(ws, store.OwnerDefault, convID)
|
||||
if err := os.MkdirAll(sandbox, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sandbox, "tracked.txt"), []byte("写入后"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cpLog, err := mgr.OpenCheckpoints(store.OwnerDefault, convID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cpLog.Record(userMsgID, sandbox, "tracked.txt", []byte("写入前"), true, []byte("写入后")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// run 行已终态与释放会话占用之间有个 settle 窗口(CI 慢机上会撞 409):
|
||||
// 有界重试直到放行。
|
||||
var code int
|
||||
var out map[string]any
|
||||
deadline2 := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID})
|
||||
if code != http.StatusConflict {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline2) {
|
||||
break
|
||||
}
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
}
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("rewind status %d: %v", code, out)
|
||||
}
|
||||
restored, _ := out["restored"].([]any)
|
||||
if len(restored) != 1 || restored[0] != "tracked.txt" {
|
||||
t.Fatalf("restored: %v", out)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(sandbox, "tracked.txt"))
|
||||
if err != nil || string(body) != "写入前" {
|
||||
t.Fatalf("文件未退回:%v %q", err, body)
|
||||
}
|
||||
}
|
||||
|
||||
// busy:会话被占时回滚返回 409。
|
||||
func TestRewindFilesHTTPBusy409(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}})
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
release := make(chan struct{})
|
||||
blocking := blockingRunner{block: make(chan struct{}, 1), release: release}
|
||||
defer close(release)
|
||||
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, blocking, blocking, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
t.Cleanup(mgr.Shutdown)
|
||||
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Bus = cmdbus.New(st, nil, nil)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
postMessage(t, h, convID, "先占住")
|
||||
|
||||
var userMsgID string
|
||||
msgs, _ := st.ListMessages(convID)
|
||||
for _, m := range msgs {
|
||||
if m.Role == core.RoleUser {
|
||||
userMsgID = m.ID
|
||||
}
|
||||
}
|
||||
code, _ := doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID})
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("busy rewind 应 409,得到 %d", code)
|
||||
}
|
||||
}
|
||||
+136
-32
@@ -27,6 +27,7 @@ import (
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
"agent-system-backend/internal/route"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
@@ -56,6 +57,27 @@ type Server struct {
|
||||
static fs.FS
|
||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||
// 摄取卡行为不变);组合根自 extension.Options 注入,零值=false=现状。
|
||||
DisableUploadIndexing bool
|
||||
// ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口
|
||||
// owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。
|
||||
ProjectAuthz func(owner string, projectID core.ID) (bool, error)
|
||||
}
|
||||
|
||||
// SetProjectAuthorizer 组合根在装载扩展后注入 EXT-8 适配器。
|
||||
func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (bool, error)) {
|
||||
s.ProjectAuthz = fn
|
||||
}
|
||||
|
||||
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
|
||||
// (fail-closed;错误不区分,避免泄露存在性)。
|
||||
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
|
||||
if s.ProjectAuthz == nil {
|
||||
return false
|
||||
}
|
||||
ok, err := s.ProjectAuthz(owner, projectID)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
|
||||
@@ -283,8 +305,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if _, err := s.st.GetProject(s.owner(r), pid); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
// EXT-8 切片 2:owner 自有未命中时咨询适配器(放行才可订阅项目流)。
|
||||
if !s.authorizeSharedProject(s.owner(r), core.ID(pid)) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
conversationID = string(core.ProjectChannelKey(core.ID(pid)))
|
||||
isProject = true
|
||||
@@ -646,20 +671,35 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 先路由再落库。回合和工作流要先抢槽位,忙则 409 且不写任何数据。
|
||||
// 意图命中的命令不占槽位,会话忙时也能用。
|
||||
route := "turn"
|
||||
// 路由裁决统一走唯一 Router(internal/route;I1 WP3)。
|
||||
routeKind := route.KindTurn
|
||||
var recognized intentgate.MatchResult
|
||||
var workflowDef core.WorkflowDef
|
||||
if s.Gate != nil && !strings.HasPrefix(text, "/") {
|
||||
recognized = s.recognize(r.Context(), text)
|
||||
if recognized.Branch == "matched" && s.Bus != nil && s.Bus.HasCommand(recognized.ID) {
|
||||
route = "command"
|
||||
} else if recognized.Branch == "matched" && s.Workflows != nil {
|
||||
if _, ok := s.Workflows.ByIntent(recognized.ID); ok {
|
||||
route = "workflow"
|
||||
}
|
||||
dec := route.Decide(recognized, route.Env{
|
||||
HasCommand: func(id string) bool { return s.Bus != nil && s.Bus.HasCommand(id) },
|
||||
WorkflowFor: func(intentID string) (core.WorkflowDef, bool) {
|
||||
if s.Workflows == nil {
|
||||
return core.WorkflowDef{}, false
|
||||
}
|
||||
return s.Workflows.ByIntent(intentID)
|
||||
},
|
||||
// SquadReady 保持 false:per-run entry 覆盖未就位(见 route 包注释的已知边界)。
|
||||
})
|
||||
switch dec.Kind {
|
||||
case route.KindCommand:
|
||||
routeKind = route.KindCommand
|
||||
recognized.ID = dec.CommandID
|
||||
recognized.Slots = dec.Slots
|
||||
case route.KindWorkflow:
|
||||
routeKind = route.KindWorkflow
|
||||
workflowDef = dec.Workflow
|
||||
recognized.Slots = dec.Slots
|
||||
}
|
||||
}
|
||||
claimed := false
|
||||
if route == "turn" || route == "workflow" {
|
||||
if routeKind == route.KindTurn || routeKind == route.KindWorkflow {
|
||||
if err := s.mgr.ClaimConversation(convID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -682,7 +722,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if route == "command" {
|
||||
if routeKind == route.KindCommand {
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
|
||||
Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID,
|
||||
@@ -701,13 +741,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var run core.Run
|
||||
var startErr error
|
||||
if route == "workflow" {
|
||||
def, _ := s.Workflows.ByIntent(recognized.ID)
|
||||
if routeKind == route.KindWorkflow {
|
||||
missing := make([]workflow.SlotQuestion, len(recognized.Missing))
|
||||
for i, q := range recognized.Missing {
|
||||
missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question}
|
||||
}
|
||||
run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, def, recognized.Slots, missing)
|
||||
run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, workflowDef, recognized.Slots, missing)
|
||||
} else {
|
||||
run, startErr = s.mgr.StartClaimedConversationRun(conv, msg, parsedDocs, inputs)
|
||||
}
|
||||
@@ -845,7 +884,7 @@ func (s *Server) listWorkspaces(w http.ResponseWriter, r *http.Request) {
|
||||
// listAudits 审计查询(会话维度或全局;入口对称的可核查凭证)。
|
||||
func (s *Server) listAudits(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
audits, err := s.st.ListAudits(r.URL.Query().Get("conversationId"), limit)
|
||||
audits, err := s.st.ListAudits(s.owner(r), r.URL.Query().Get("conversationId"), limit)
|
||||
writeJSONOr(w, map[string]any{"audits": audits}, err)
|
||||
}
|
||||
|
||||
@@ -1022,11 +1061,14 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.Cards.Update(owner, ingestCard)
|
||||
}
|
||||
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
|
||||
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
|
||||
if s.Indexer != nil {
|
||||
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
|
||||
} else {
|
||||
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
|
||||
if !s.DisableUploadIndexing {
|
||||
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
|
||||
if s.Indexer != nil {
|
||||
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
|
||||
} else {
|
||||
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc})
|
||||
@@ -1045,26 +1087,61 @@ func kindOf(filename string) string {
|
||||
|
||||
func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
pid := r.PathValue("id")
|
||||
if _, err := s.st.GetProject(owner, pid); err != nil {
|
||||
// EXT-8:owner 自有未命中时问适配器;放行则返回项目全部工件(只读共享)。
|
||||
if !s.authorizeSharedProject(owner, core.ID(pid)) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListProjectArtifactsAll(pid)
|
||||
writeJSONOr(w, as, err)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListArtifacts(owner, r.PathValue("id"))
|
||||
as, err := s.st.ListArtifacts(owner, pid)
|
||||
writeJSONOr(w, as, err)
|
||||
}
|
||||
|
||||
// sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器,
|
||||
// 放行才返回;否则保持 NotFound(不泄露存在性)。
|
||||
func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) {
|
||||
a, err := s.st.GetArtifactByID(id)
|
||||
if err != nil {
|
||||
return core.Artifact{}, false
|
||||
}
|
||||
if !s.authorizeSharedProject(owner, a.ProjectID) {
|
||||
return core.Artifact{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id"))
|
||||
owner := s.owner(r)
|
||||
a, err := s.st.GetArtifact(owner, r.PathValue("id"))
|
||||
if err != nil {
|
||||
shared, ok := s.sharedArtifactGet(owner, r.PathValue("id"))
|
||||
if !ok {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shared)
|
||||
return
|
||||
}
|
||||
writeJSONOr(w, a, err)
|
||||
}
|
||||
|
||||
func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id"))
|
||||
owner := s.owner(r)
|
||||
a, err := s.st.GetArtifact(owner, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
shared, ok := s.sharedArtifactGet(owner, r.PathValue("id"))
|
||||
if !ok {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
a = shared
|
||||
}
|
||||
content, err := s.st.ArtifactContent(s.owner(r), a.ID)
|
||||
content, err := s.st.ArtifactContentByID(a.ID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1164,7 +1241,7 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// visibleTools Hidden(预留能力)工具按租户生效设置过滤:runtime.fsTools 未开不可见。
|
||||
func (s *Server) visibleTools(owner string) []core.ToolDesc {
|
||||
list := s.reg.List()
|
||||
list := tools.FilterExecDisabled(s.reg.List())
|
||||
if s.set.Effective(owner, "").GetBool("runtime.fsTools", false) {
|
||||
return list
|
||||
}
|
||||
@@ -1396,12 +1473,39 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
|
||||
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
|
||||
if s.Retriever != nil {
|
||||
hits, err := s.Retriever.Retrieve(r.Context(), s.owner(r), query)
|
||||
owner := s.owner(r)
|
||||
// EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版;
|
||||
// 否则维持切片 2 行为(本人检索 + 全文共享追加)。
|
||||
allow := func(docOwner, projectID core.ID) bool {
|
||||
return s.authorizeSharedProject(owner, projectID)
|
||||
}
|
||||
if rs, ok := s.Retriever.(interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}); ok && s.Retriever != nil {
|
||||
hits, err := rs.RetrieveShared(r.Context(), core.ID(owner), query, allow)
|
||||
writeJSONOr(w, hits, err)
|
||||
return
|
||||
}
|
||||
hits, err := s.st.SearchKnowledge(s.owner(r), query)
|
||||
var hits []core.RetrievalHit
|
||||
var err error
|
||||
if s.Retriever != nil {
|
||||
hits, err = s.Retriever.Retrieve(r.Context(), owner, query)
|
||||
} else {
|
||||
hits, err = s.st.SearchKnowledge(owner, query)
|
||||
}
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
shared, serr := s.st.SearchKnowledgeShared(owner, query, allow)
|
||||
if serr != nil {
|
||||
writeStoreErr(w, serr)
|
||||
return
|
||||
}
|
||||
hits = append(hits, shared...)
|
||||
if limit := query.TopK; limit > 0 && len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
writeJSONOr(w, hits, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EXT-8 切片 2(红测):适配器放行后,他人 owner 的项目知识对本人可检索;
|
||||
// 未放行/第三方保持零命中(不泄露存在性)。
|
||||
func TestSharedKnowledgeRetrieval(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// alice 建项目并写入带 ProjectID 的知识(模拟其已发布内容)。
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "S2"})))
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create project: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var pj map[string]any
|
||||
readJSON(t, rec.Body.Bytes(), &pj)
|
||||
pid := fmt.Sprint(pj["id"])
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: core.ID(pid),
|
||||
Source: "plugin:rdm", Ref: "kb-1", Chunk: "共享检索独占词 甲"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
search := func(key string) int {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q=%E5%85%B1%E4%BA%AB%E6%A3%80%E7%B4%A2%E7%8B%AC%E5%8D%A0%E8%AF%8D", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var hits []any
|
||||
readJSON(t, rec.Body.Bytes(), &hits)
|
||||
return len(hits)
|
||||
}
|
||||
|
||||
if n := search(alice); n != 1 {
|
||||
t.Fatalf("alice own search = %d, want 1", n)
|
||||
}
|
||||
if n := search(bob); n != 0 {
|
||||
t.Fatalf("bob before grant = %d, want 0", n)
|
||||
}
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "bob" && string(projectID) == pid, nil
|
||||
})
|
||||
if n := search(bob); n != 1 {
|
||||
t.Fatalf("bob after grant = %d, want 1(适配器放行后共享知识可检索)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func readJSON(t *testing.T, b []byte, v any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(b, v); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, b)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON2(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// EXT-8 切片 2(红测):SSE 项目流订阅门——owner 自有未命中时咨询适配器;
|
||||
// 未放行 404(不泄露存在性),放行可订阅(事件头即证)。
|
||||
func TestProjectSSEScopeGate(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
_ = st
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "SSE"})))
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var pj map[string]any
|
||||
readJSON(t, rec.Body.Bytes(), &pj)
|
||||
pid := fmt.Sprint(pj["id"])
|
||||
|
||||
srvSSE := httptest.NewServer(h)
|
||||
t.Cleanup(srvSSE.Close)
|
||||
sub := func(key string) int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
srvSSE.URL+"/api/events?scope=project&projectId="+pid, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp, err := srvSSE.Client().Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return http.StatusOK // 超时前未断流:视为已放行进入流(首字节已收)
|
||||
}
|
||||
t.Fatalf("sse: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
if c := sub(bob); c != http.StatusNotFound {
|
||||
t.Fatalf("bob before grant: %d, want 404", c)
|
||||
}
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "bob" && string(projectID) == pid, nil
|
||||
})
|
||||
if c := sub(bob); c != http.StatusOK {
|
||||
t.Fatalf("bob after grant: %d, want 200(适配器放行后可订阅项目流)", c)
|
||||
}
|
||||
if c := sub(alice); c != http.StatusOK {
|
||||
t.Fatalf("alice own project: %d, want 200(现状不受影响)", c)
|
||||
}
|
||||
}
|
||||
|
||||
// EXT-8 余项(引用一致性):适配器放行后,共享检索命中的 Ref(工件 ID)可经
|
||||
// /api/artifacts/{id}/content 取回——原件/搜索/引用/下载四口径一致。
|
||||
func TestSharedCitationRefDownloadable(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "引用链"})))
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var pj map[string]any
|
||||
readJSON(t, rec.Body.Bytes(), &pj)
|
||||
pid := fmt.Sprint(pj["id"])
|
||||
|
||||
// alice 上传原件(走真实 upload,含索引),并以其 artifactId 为共享知识的 Ref。
|
||||
up := uploadMultipart(t, h, alice, pid, "引用源.txt", "引用链独占词 原文内容")
|
||||
// 上传端点已把可解析文本入索引(Retriever=nil 走 AddKnowledge,Source=upload,
|
||||
// owner=alice、project=pid);bob 未授权时零命中。
|
||||
if n := searchCount(t, h, bob, "引用链独占词"); n != 0 {
|
||||
t.Fatalf("bob before grant = %d, want 0", n)
|
||||
}
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "bob" && string(projectID) == pid, nil
|
||||
})
|
||||
if n := searchCount(t, h, bob, "引用链独占词"); n != 1 {
|
||||
t.Fatalf("bob after grant = %d, want 1(共享命中,Ref=工件)", n)
|
||||
}
|
||||
// 共享命中的 Ref(=上传工件 ID)可下载(引用→原件一致)。
|
||||
reqC := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+up+"/content", nil)
|
||||
reqC.Header.Set("Authorization", "Bearer "+bob)
|
||||
recC := httptest.NewRecorder()
|
||||
h.ServeHTTP(recC, reqC)
|
||||
if recC.Code != http.StatusOK || !bytes.Contains(recC.Body.Bytes(), []byte("原文内容")) {
|
||||
t.Fatalf("shared citation ref download: %d %q", recC.Code, recC.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func uploadMultipart(t *testing.T, h http.Handler, key, pid, name, text string) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("file", name)
|
||||
fw.Write([]byte(text))
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var up struct {
|
||||
ArtifactID string `json:"artifactId"`
|
||||
}
|
||||
readJSON(t, rec.Body.Bytes(), &up)
|
||||
return up.ArtifactID
|
||||
}
|
||||
|
||||
func searchCount(t *testing.T, h http.Handler, key, q string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q=%E5%BC%95%E7%94%A8%E9%93%BE%E7%8B%AC%E5%8D%A0%E8%AF%8D", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var hits []any
|
||||
readJSON(t, rec.Body.Bytes(), &hits)
|
||||
return len(hits)
|
||||
}
|
||||
@@ -94,6 +94,10 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
|
||||
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
|
||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
|
||||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
||||
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Description: "OTLP resource 的 service.name"},
|
||||
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
||||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||||
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: docx-author
|
||||
description: 用 housestyle 生成可直接交付的 Word(.docx)
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: house-style
|
||||
description: 交付物设计系统。用 housestyle 生成 Word、Excel、PowerPoint,并在登记前自检。
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: pptx-author
|
||||
description: 用 housestyle 生成 16:9 的 PowerPoint(.pptx)
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: xlsx-author
|
||||
description: 用 housestyle 生成带表头、公式和说明页的 Excel(.xlsx)
|
||||
---
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -80,6 +81,14 @@ func Seed(dir string) (SeedResult, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// SKILL.md 种子写入前过开放规范校验(recipes 全 dogfood 合规;
|
||||
// 不合规拒写并上浮——错误不得静默落盘)。
|
||||
if d.Name() == "SKILL.md" {
|
||||
skillName := filepath.Base(filepath.Dir(rel))
|
||||
if verr := validateBuiltin(skillName, data); verr != nil {
|
||||
return fmt.Errorf("builtin skill %s: %w", skillName, verr)
|
||||
}
|
||||
}
|
||||
embedded := hashBytes(data)
|
||||
dst := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
disk, err := os.ReadFile(dst)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
agentskills "agent-native/runtime/skills"
|
||||
)
|
||||
|
||||
// ValidateForPlugin 用 agent-native 的同一校验器验证插件技能
|
||||
// (开放规范:name 必填且与目录同名、description 必填、已知键白名单)。
|
||||
// 失败带插件上下文与字段定位,由调用方整批拒绝该插件(fail-closed,
|
||||
// 与 agents/ 资产的禁带键同款形态)。
|
||||
func ValidateForPlugin(pluginID, name string, body []byte) error {
|
||||
if err := agentskills.ValidateSkillDocument(name, "plugins/"+pluginID+"/skills/"+name+".md", body); err != nil {
|
||||
return fmt.Errorf("plugins: %s: skill %s: %w", pluginID, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBuiltin 校验内置配方(种子写入前置;recipes 全部 dogfood 合规)。
|
||||
func validateBuiltin(name string, body []byte) error {
|
||||
return agentskills.ValidateSkillDocument(name, "recipes/"+name+"/SKILL.md", body)
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
// 装配映射:
|
||||
// - RoleRegistry → 内置小队角色(规则规划器自主编排四阶段骨架)
|
||||
// - Planner → squad RulePlanner(确定性;LLM 规划器为后续档)
|
||||
// - BackendResolver→ 自定义 "agent-system" 后端工厂:每步执行 = rc.SpawnSubagent
|
||||
// (子 Run 进任务树,走既有 mock/LLM 子代理执行器与事件)
|
||||
// - BackendResolver→ "agent-native" 内核后端工厂(Q3 WP2:SpawnSubagent
|
||||
// 压缩路径已退役;每步执行 = agent-native 内核循环)
|
||||
// - ProgressSink → 步骤事件映射 plan.update / agent.progress
|
||||
// - HITL(挂起) → 引擎 Pending HumanRequest ↔ rc.AskHITL 卡片;RespondDecision 回填
|
||||
package squadadapter
|
||||
@@ -13,7 +13,6 @@ package squadadapter
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -69,14 +68,14 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
backends := buildBackends(rc)
|
||||
backends := BuildBackends(rc)
|
||||
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: runDir,
|
||||
Registry: registry,
|
||||
Planner: squad.NewRulePlanner(),
|
||||
Backends: backends,
|
||||
DefaultBackend: "agent-system",
|
||||
DefaultBackend: "agent-native",
|
||||
StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟)
|
||||
HitlPolicy: squad.HitlPolicy{Mode: squad.HitlInteractive},
|
||||
Checker: quality.Checker{},
|
||||
@@ -147,70 +146,6 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// stepBackend:小队步骤 → 统一子 Run(任务树可见;执行走 runmgr 的子代理执行器)。
|
||||
type stepBackend struct {
|
||||
rc Ctx
|
||||
role squad.RoleSpec
|
||||
}
|
||||
|
||||
func (b *stepBackend) Name() string { return "agent-system" }
|
||||
|
||||
// profileForRole 把小队角色键映射到子代理 profile。未列出的角色走 general。
|
||||
func profileForRole(role string) string {
|
||||
switch role {
|
||||
case "planner", "organizer":
|
||||
return "agent.sub.plan"
|
||||
case "researcher", "analyst":
|
||||
return "agent.sub.research"
|
||||
case "writer":
|
||||
return "agent.sub.writer"
|
||||
case "reviewer", "leader":
|
||||
return "agent.sub.verify"
|
||||
default:
|
||||
return "agent.sub.general"
|
||||
}
|
||||
}
|
||||
|
||||
func (b *stepBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
task := strings.TrimSpace(req.Prompt)
|
||||
if task == "" {
|
||||
task = req.Instructions
|
||||
}
|
||||
hint := strings.TrimSpace(req.Instructions)
|
||||
name := firstNonEmpty(b.role.Name, b.role.Role)
|
||||
var out core.AgentResultPayload
|
||||
var err error
|
||||
if sp, ok := b.rc.(interface {
|
||||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
}); ok {
|
||||
out, err = sp.SpawnAgent(profileForRole(b.role.Role), name, task, hint)
|
||||
} else {
|
||||
out, err = b.rc.SpawnSubagent(name, task, hint)
|
||||
}
|
||||
res := &squad.ExecResult{Success: err == nil, Logs: firstNonEmpty(out.Summary, "子代理完成")}
|
||||
if err != nil {
|
||||
res.ErrorReason = err.Error()
|
||||
return res, nil
|
||||
}
|
||||
// 交付物:子代理全文写入步骤子工作区的目标文件(引擎契约:后端自行落盘,
|
||||
// 检查器按 allTargetsPresent 校验;Patches 同时回传供引擎重试路径复用)。
|
||||
content := firstNonEmpty(out.Text, out.Summary, "(子代理无文本产出)")
|
||||
targets := req.Targets
|
||||
if len(targets) == 0 {
|
||||
targets = []string{"output.md"}
|
||||
}
|
||||
res.Patches = make(map[string][]byte, len(targets))
|
||||
for _, t := range targets {
|
||||
body := []byte(content + "\n")
|
||||
res.Patches[t] = body
|
||||
dest := filepath.Join(req.WorkDir, filepath.FromSlash(t))
|
||||
if merr := os.MkdirAll(filepath.Dir(dest), 0o755); merr == nil {
|
||||
_ = os.WriteFile(dest, body, 0o644)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// askHuman:HumanRequest → HITL 卡片 → RespondDecision(选项直达 / 自由文本作答)。
|
||||
func askHuman(rc Ctx, req *squad.HumanRequest) (squad.RespondDecision, error) {
|
||||
choices := make([]core.HITLChoice, 0, len(req.Options))
|
||||
@@ -313,6 +248,28 @@ func SubagentFactory(rc Ctx) subagent.Factory {
|
||||
if len(req.Deliverables) > 0 {
|
||||
b.WriteString("\n交付物:" + strings.Join(req.Deliverables, ", "))
|
||||
}
|
||||
// Q3 WP3:任务书四要素补全——目标、输出格式、工具和边界。空声明
|
||||
// 不落节(沿用子代理画像默认)。
|
||||
if strings.TrimSpace(req.OutputFormat) != "" {
|
||||
b.WriteString("\n输出格式:" + req.OutputFormat)
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
b.WriteString("\n工具面(可用工具,不得越界):" + strings.Join(req.Tools, ", "))
|
||||
}
|
||||
// Background(N2 WP5):声明驱动后台派生,语义同产品 delegate 的
|
||||
// background 参数;完成结果经通知队列回投,本次调用立即返回 run ID。
|
||||
// rc 无后台能力(测试桩/旧宿主)时回落同步派生,不 fail-closed。
|
||||
if req.Background {
|
||||
if bg, ok := rc.(interface {
|
||||
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
|
||||
}); ok {
|
||||
id, err := bg.SpawnBackground("delegatee", "delegatee", b.String(), "内核 delegate_task 委派(后台)")
|
||||
if err != nil {
|
||||
return subagent.Result{}, err
|
||||
}
|
||||
return subagent.Result{Success: true, Text: "已在后台启动 " + id + ",完成后会自动通知"}, nil
|
||||
}
|
||||
}
|
||||
payload, err := rc.SpawnSubagent("delegatee", b.String(), "内核 delegate_task 委派")
|
||||
if err != nil {
|
||||
return subagent.Result{}, err
|
||||
@@ -328,15 +285,12 @@ func SubagentFactory(rc Ctx) subagent.Factory {
|
||||
}
|
||||
}
|
||||
|
||||
// buildBackends 组装小队后端解析器:"agent-system"(SpawnSubagent 路径)恒注册;
|
||||
// 组合根注入 AgentNativeOptions 时额外注册 "agent-native" 内核工厂——角色显式
|
||||
// 声明 Backend:"agent-native" 才会走内核执行;默认后端仍是 agent-system,
|
||||
// 不因工厂注册而切换(对齐 parity 渐进启用条款)。
|
||||
func buildBackends(rc Ctx) *squad.BackendResolver {
|
||||
// BuildBackends 组装小队后端解析器(Q3 WP2):只注册 "agent-native" 内核
|
||||
// 工厂(组合根注入 AgentNativeOptions 且模型已配置时)。legacy 的
|
||||
// "agent-system"(SpawnSubagent 压缩路径)已退役删除;工厂缺席时解析
|
||||
// fail-closed——步骤失败可见,不静默降级到别的执行面。
|
||||
func BuildBackends(rc Ctx) *squad.BackendResolver {
|
||||
backends := squad.NewBackendResolver()
|
||||
backends.Register("agent-system", func(role squad.RoleSpec) (squad.Executor, error) {
|
||||
return &stepBackend{rc: rc, role: role}, nil
|
||||
})
|
||||
if AgentNativeOptions != nil {
|
||||
if cfg, ok := AgentNativeOptions(rc); ok {
|
||||
nativeadp.RegisterFull(backends, cfg)
|
||||
@@ -358,23 +312,24 @@ func builtinRoles() []squad.RoleSpec {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
// 执行配置显式化(agent-native 后端消费;agent-system 路径忽略不生效但保持声明一致):
|
||||
// 执行配置显式化(agent-native 后端消费):
|
||||
// 规划/调研/审校一律 BashPermission=deny(无命令执行需求);
|
||||
// 撰稿员需要 run_python 生成文档——ask 档(每次运行首次使用前人工放行)。
|
||||
// 工具白名单按角色最小化;撰稿员技能指向启动种子的三套交付配方。
|
||||
// 撰稿员需要 run_python/run_command 生成文档——ask 档(每次运行首次使用前人工放行)。
|
||||
// 工具白名单按角色最小化(Q3 WP2:agent-native 下步骤须自己落盘交付物,
|
||||
// 全员补 write_file);撰稿员技能指向启动种子的三套交付配方。
|
||||
return []squad.RoleSpec{
|
||||
{Role: "planner", Name: "规划员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
|
||||
Tools: []string{"read_file", "list_dir", "grep"},
|
||||
{Role: "planner", Name: "规划员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
|
||||
Tools: []string{"read_file", "list_dir", "grep", "write_file"},
|
||||
Keywords: []string{"梳理", "规划", "分析"}, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")},
|
||||
{Role: "researcher", Name: "调研员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
|
||||
{Role: "researcher", Name: "调研员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
|
||||
Tools: []string{"web_search", "tavily_search", "fetch_url", "read_file", "write_file"},
|
||||
Keywords: []string{"调研", "检索", "资料"}, Instructions: instr("researcher", "围绕目标调研关键问题,输出简明结论与依据。")},
|
||||
{Role: "writer", Name: "撰稿员", Backend: "agent-system", BashPermission: squad.BashPermissionAsk,
|
||||
Tools: []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "load_skill", "run_python", "run_shell"},
|
||||
{Role: "writer", Name: "撰稿员", Backend: "agent-native", BashPermission: squad.BashPermissionAsk,
|
||||
Tools: []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "load_skill", "run_python", "run_command"},
|
||||
Skills: []string{"docx-author", "xlsx-author", "pptx-author"},
|
||||
Keywords: []string{"撰写", "成文", "报告"}, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")},
|
||||
{Role: "reviewer", Name: "审校员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
|
||||
Tools: []string{"read_file", "grep", "glob", "list_dir"},
|
||||
{Role: "reviewer", Name: "审校员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
|
||||
Tools: []string{"read_file", "grep", "glob", "list_dir", "write_file"},
|
||||
Keywords: []string{"审校", "把关", "终审"}, Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ func TestBuiltinRolesValid(t *testing.T) {
|
||||
if err := reg.Register(r); err != nil {
|
||||
t.Errorf("role %s invalid: %v", r.Role, err)
|
||||
}
|
||||
if r.Backend != "agent-system" {
|
||||
t.Errorf("role %s backend: %s", r.Role, r.Backend)
|
||||
if r.Backend != "agent-native" {
|
||||
t.Errorf("role %s backend: %s", r.Backend, r.Backend)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,8 @@ func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) {
|
||||
Goal: "查证数据", Inputs: "docs/v2", WriteScope: "research/",
|
||||
NonGoals: "不做实现", DoneCriteria: "三来源对照",
|
||||
Deliverables: []string{"notes.md"},
|
||||
OutputFormat: "Markdown:结论先行,附对照表",
|
||||
Tools: []string{"web_search", "read_file"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -92,7 +94,8 @@ func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) {
|
||||
if !res.Success || res.Text != "子代理结论" {
|
||||
t.Fatalf("bridge result wrong: %+v", res)
|
||||
}
|
||||
for _, want := range []string{"查证数据", "docs/v2", "research/", "不做实现", "三来源对照", "notes.md"} {
|
||||
for _, want := range []string{"查证数据", "docs/v2", "research/", "不做实现", "三来源对照", "notes.md",
|
||||
"输出格式:Markdown:结论先行,附对照表", "工具面(可用工具,不得越界):web_search, read_file"} {
|
||||
if !strings.Contains(rc.gotTask, want) {
|
||||
t.Fatalf("task brief missing %q:\n%s", want, rc.gotTask)
|
||||
}
|
||||
@@ -115,53 +118,6 @@ func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type agentFakeRc struct {
|
||||
subagentFakeRc
|
||||
agentID string
|
||||
}
|
||||
|
||||
func (f *agentFakeRc) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||||
f.agentID = agentID
|
||||
f.gotName, f.gotTask = name, task
|
||||
return f.payload, f.err
|
||||
}
|
||||
|
||||
func TestStepRoleMapsToProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cases := []struct {
|
||||
role string
|
||||
want string
|
||||
}{
|
||||
{"planner", "agent.sub.plan"},
|
||||
{"organizer", "agent.sub.plan"},
|
||||
{"researcher", "agent.sub.research"},
|
||||
{"analyst", "agent.sub.research"},
|
||||
{"writer", "agent.sub.writer"},
|
||||
{"reviewer", "agent.sub.verify"},
|
||||
{"leader", "agent.sub.verify"},
|
||||
{"specialist", "agent.sub.general"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rc := &agentFakeRc{subagentFakeRc: subagentFakeRc{payload: core.AgentResultPayload{Text: "ok"}}}
|
||||
b := &stepBackend{rc: rc, role: squad.RoleSpec{Role: tc.role, Name: "显示名"}}
|
||||
res, err := b.Execute(context.Background(), squad.ExecRequest{Prompt: "做这件事", WorkDir: dir, Targets: []string{"output.md"}})
|
||||
if err != nil || !res.Success {
|
||||
t.Fatalf("%s: %+v %v", tc.role, res, err)
|
||||
}
|
||||
if rc.agentID != tc.want || rc.gotName != "显示名" {
|
||||
t.Fatalf("%s mapped to %s name %s", tc.role, rc.agentID, rc.gotName)
|
||||
}
|
||||
}
|
||||
plain := &subagentFakeRc{payload: core.AgentResultPayload{Text: "ok"}}
|
||||
b := &stepBackend{rc: plain, role: squad.RoleSpec{Role: "writer", Name: "撰稿"}}
|
||||
if _, err := b.Execute(context.Background(), squad.ExecRequest{Prompt: "写", WorkDir: dir}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain.gotName != "撰稿" {
|
||||
t.Fatalf("fallback name = %s", plain.gotName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinRolesExplicitExecConfig(t *testing.T) {
|
||||
roles := builtinRoles()
|
||||
byKey := map[string]squad.RoleSpec{}
|
||||
@@ -210,3 +166,16 @@ func containsStr(list []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Q3 WP2:agent-system 退役后,未注入 AgentNativeOptions 时解析器为空——
|
||||
// 步骤后端解析 fail-closed,不再静默走 SpawnSubagent 压缩路径。
|
||||
func TestBuildBackendsWithoutNativeOptionsIsEmpty(t *testing.T) {
|
||||
saved := AgentNativeOptions
|
||||
AgentNativeOptions = nil
|
||||
defer func() { AgentNativeOptions = saved }()
|
||||
r := BuildBackends(&subagentFakeRc{})
|
||||
if _, _, err := r.Resolve(squad.RoleSpec{Role: "writer", Backend: "agent-native"}, "agent-native"); err == nil {
|
||||
t.Fatal("无内核工厂时解析必须 fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) {
|
||||
return EntryResult{}, fmt.Errorf("squad entry: register %s: %w", role.Role, err)
|
||||
}
|
||||
}
|
||||
backends := buildBackends(rc)
|
||||
backends := BuildBackends(rc)
|
||||
nativeadp.RegisterFull(backends, cfg)
|
||||
|
||||
checker := opt.Checker
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package squadadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
osexec "os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
nativetools "agent-native/runtime/tools"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
"agent-system-backend/internal/skills"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type orchFakeRc struct {
|
||||
subagentFakeRc
|
||||
dir string
|
||||
}
|
||||
|
||||
func (f *orchFakeRc) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
if len(card.Choices) > 0 {
|
||||
return core.HITLResponse{ChoiceID: card.Choices[0].ID}, nil
|
||||
}
|
||||
return core.HITLResponse{ChoiceID: "approve"}, nil
|
||||
}
|
||||
|
||||
// TestOrchestrateRunsOnKernelStub Q3 WP2 行为面:agent-system 退役后,
|
||||
// Orchestrate 的四阶段骨架经 agent-native 内核(桩 Stream + write_file 落盘
|
||||
// 交付物 + 种子技能 + Exec 白名单)跑通并产出交付物工件。
|
||||
func TestOrchestrateRunsOnKernelStub(t *testing.T) {
|
||||
skillsRoot := t.TempDir()
|
||||
if _, serr := skills.Seed(skillsRoot); serr != nil {
|
||||
t.Fatal(serr)
|
||||
}
|
||||
saved := AgentNativeOptions
|
||||
AgentNativeOptions = func(Ctx) (nativeadp.Config, bool) {
|
||||
py, _ := osexec.LookPath("python3")
|
||||
return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot},
|
||||
Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubStream("# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
|
||||
}
|
||||
defer func() { AgentNativeOptions = saved }()
|
||||
rc := &orchFakeRc{dir: t.TempDir()}
|
||||
dataDir := t.TempDir()
|
||||
summary, err := Orchestrate(rc, dataDir, "生成一份示例目标简报")
|
||||
t.Logf("summary = %q", summary)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !strings.Contains(summary, "completed") || !strings.Contains(summary, "完成 4") {
|
||||
t.Fatalf("summary = %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func stubStream(text string) agentllm.StreamFunc {
|
||||
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 8)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
finish := func() {
|
||||
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
|
||||
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: text}}
|
||||
}
|
||||
prompt := ""
|
||||
for _, m := range msgs {
|
||||
if m.Role == agentllm.RoleTool {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if m.Role == agentllm.RoleUser {
|
||||
prompt = m.Content
|
||||
}
|
||||
}
|
||||
i := strings.Index(prompt, "期望产出:[")
|
||||
if i < 0 {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
j := strings.Index(prompt[i:], "]")
|
||||
if j < 0 {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
targets := strings.Fields(prompt[i+len("期望产出:[") : i+j])
|
||||
var calls []agentllm.ToolCall
|
||||
for k, tgt := range targets {
|
||||
args := fmt.Sprintf(`{"path":%q,"content":%q}`, tgt, text)
|
||||
id := fmt.Sprintf("call_stub_%d", k+1)
|
||||
ch <- agentllm.Event{Kind: agentllm.KindToolCallDelta, ToolIndex: k, ToolID: id, ToolName: "write_file", ArgumentsDelta: args}
|
||||
calls = append(calls, agentllm.ToolCall{ID: id, Name: "write_file", Arguments: args})
|
||||
}
|
||||
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, ToolCalls: calls, StopReason: agentllm.StopToolCalls}}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
}
|
||||
+18
-9
@@ -8,21 +8,30 @@ import (
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
// AppendAudit 追加命令审计信封(cmdbus 端口;v7 command_audit)。
|
||||
func (s *Store) AppendAudit(env agentcommand.AuditEnvelope) error {
|
||||
// AppendAudit 追加命令审计信封(cmdbus 端口;v7 建表,v18 增加 owner 列)。
|
||||
// owner 是租户身份(认证注入),与信封里的 Actor(发起主体)语义不同:查询按
|
||||
// owner 过滤,跨租户审计不可见。
|
||||
func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State),
|
||||
env.OperationID, env.ConversationID, string(raw))
|
||||
env.OperationID, env.ConversationID, owner, string(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAudits 审计查询(时间倒序;conversationID 空 = 全局)。
|
||||
func (s *Store) ListAudits(conversationID string, limit int) ([]agentcommand.AuditEnvelope, error) {
|
||||
// ListAudits 审计查询(owner 作用域,时间倒序;conversationID 空 = 该租户全局)。
|
||||
// owner 为空视为无作用域:不返回任何行(防调用方漏传租户时把未归属存量行
|
||||
// 全部泄出——服务端认证层恒注入非空 owner,这里是纵深防御)。
|
||||
// v18 回填时 actor 为空的存量行 owner 保持空串,不匹配任何租户:这些行不再经
|
||||
// API 返回(数据保留在表内,可直查数据库取证)——见 v18 迁移注释。
|
||||
func (s *Store) ListAudits(owner, conversationID string, limit int) ([]agentcommand.AuditEnvelope, error) {
|
||||
if owner == "" {
|
||||
return []agentcommand.AuditEnvelope{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -31,9 +40,9 @@ func (s *Store) ListAudits(conversationID string, limit int) ([]agentcommand.Aud
|
||||
err error
|
||||
)
|
||||
if conversationID == "" {
|
||||
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit ORDER BY time DESC LIMIT ?`), limit)
|
||||
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? ORDER BY time DESC LIMIT ?`), owner, limit)
|
||||
} else {
|
||||
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE conversation_id=? ORDER BY time DESC LIMIT ?`), conversationID, limit)
|
||||
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? AND conversation_id=? ORDER BY time DESC LIMIT ?`), owner, conversationID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
// v18 迁移:command_audit 增加 owner 列并按 payload.actor 回填存量行
|
||||
// (GET /api/commands/audit 曾跨租户可读;见 migrate.go v18 注释)。
|
||||
// 路径:全新库(已到 v18)→ MigrateDown(17) 回出无 owner 列的旧表 →
|
||||
// 直插两行修复前形态的信封 → 重开触发 v18 → 归属断言。
|
||||
func TestCommandAuditOwnerBackfill(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.MigrateDown(17); err != nil {
|
||||
t.Fatalf("migrate down to v17: %v", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
legacy := func(actor, cmd string) string {
|
||||
// 修复前形态:信封 Actor 即发起租户,表内无归属列。
|
||||
return `{"time":"` + now + `","actor":"` + actor + `","commandId":"` + cmd + `","source":"builtin","state":"committed"}`
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload)
|
||||
VALUES (?, 'cmd.a', 'builtin', 'api', 'committed', '', 'c1', ?)`), now, legacy("alice", "cmd.a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload)
|
||||
VALUES (?, 'cmd.x', 'builtin', 'api', 'committed', '', '', ?)`), now, `{"time":"`+now+`","commandId":"cmd.x","state":"committed"}`); err != nil {
|
||||
t.Fatal(err) // actor 为空的存量行:无法归属
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st2, err := OpenSQLite(path) // 重开触发 v18:加列 + 回填
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st2.Close()
|
||||
|
||||
aliceRows, err := st2.ListAudits("alice", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(aliceRows) != 1 || aliceRows[0].CommandID != "cmd.a" {
|
||||
t.Fatalf("alice rows = %+v, want 1 条 cmd.a(actor 回填归属)", aliceRows)
|
||||
}
|
||||
bobRows, err := st2.ListAudits("bob", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bobRows) != 0 {
|
||||
t.Fatalf("bob rows = %+v, want 0", bobRows)
|
||||
}
|
||||
if rows, _ := st2.ListAudits("", "", 10); len(rows) != 0 {
|
||||
t.Fatalf("空 owner(actor 缺失的存量行)不得经 API 归属返回,got %d 行", len(rows))
|
||||
}
|
||||
|
||||
// 新写入走 owner 列;同租户按会话过滤仍可用。
|
||||
if err := st2.AppendAudit("alice", agentcommand.AuditEnvelope{CommandID: "cmd.a2", Actor: "alice",
|
||||
State: agentcommand.StateCommitted, ConversationID: "c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convRows, err := st2.ListAudits("alice", "c2", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(convRows) != 1 || convRows[0].CommandID != "cmd.a2" {
|
||||
t.Fatalf("alice/c2 rows = %+v, want cmd.a2", convRows)
|
||||
}
|
||||
}
|
||||
+174
-6
@@ -111,6 +111,51 @@ func (s *Store) ListArtifacts(owner, projectID string) ([]core.Artifact, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- EXT-8 共享读取助手:无 owner 过滤,仅供 HTTP 层在
|
||||
// authorizeSharedProject 判定通过后使用;其他调用点一律禁止(会破坏租户边界)。----
|
||||
|
||||
// GetArtifactByID 按工件 ID 取元数据(无 owner 过滤;调用方须先过 EXT-8 判定)。
|
||||
func (s *Store) GetArtifactByID(id string) (core.Artifact, error) {
|
||||
a, err := scanArtifact(func(dst ...any) error {
|
||||
return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=?`), id).Scan(dst...)
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return a, ErrNotFound
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
// ArtifactContentByID 按工件 ID 取内容(无 owner 过滤;调用方须先过 EXT-8 判定)。
|
||||
func (s *Store) ArtifactContentByID(id string) ([]byte, error) {
|
||||
var ref string
|
||||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=?`), id).Scan(&ref)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.blobs.Get(ref)
|
||||
}
|
||||
|
||||
// ListProjectArtifactsAll 项目全部工件(含其他 owner;调用方须先过 EXT-8 判定)。
|
||||
func (s *Store) ListProjectArtifactsAll(projectID string) ([]core.Artifact, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? ORDER BY updated_at DESC`), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []core.Artifact{}
|
||||
for rows.Next() {
|
||||
a, err := scanArtifact(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteArtifact 先删行后删对象:崩溃间隙只可能残留无主文件(可 GC),不悬空引用。
|
||||
func (s *Store) DeleteArtifact(owner, id string) error {
|
||||
var path string
|
||||
@@ -267,6 +312,40 @@ func (s *Store) DeleteKnowledgeByRef(owner, ref string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteKnowledgeBySource 按来源前缀撤销知识(EXT-9):删除该 owner 下 source 匹配
|
||||
// 前缀的行,并经 ref 关联清理向量;幂等(无匹配返回 0, nil)。供宿主在
|
||||
// KnowledgeDelete(前缀已强制 plugin:<插件ID>)后调用——不作用他人来源。
|
||||
func (s *Store) DeleteKnowledgeBySource(ctx context.Context, owner, sourcePrefix string) (int64, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q(`SELECT DISTINCT ref FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
refs := []string{}
|
||||
for rows.Next() {
|
||||
var r string
|
||||
if err := rows.Scan(&r); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
refs = append(refs, r)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, r := range refs {
|
||||
if err := s.DeleteKnowledgeVectors(ctx, owner, r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, s.q(`DELETE FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *Store) SearchKnowledge(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
if s.dialect.name() == "postgres" {
|
||||
return s.searchKnowledgePG(owner, q)
|
||||
@@ -274,7 +353,72 @@ func (s *Store) SearchKnowledge(owner string, q core.RetrievalQuery) ([]core.Ret
|
||||
return s.searchKnowledgeSQLite(owner, q)
|
||||
}
|
||||
|
||||
// knowledgePred 命中可见性谓词(owner 作用域或共享作用域由调用方决定)。
|
||||
type knowledgePred func(d core.KnowledgeDoc) bool
|
||||
|
||||
func ownerVisible(owner string, q core.RetrievalQuery) knowledgePred {
|
||||
return func(d core.KnowledgeDoc) bool { return knowledgeVisible(owner, q, d) }
|
||||
}
|
||||
|
||||
// sharedVisible EXT-8 切片 2:他人 owner 且其 ProjectID 经授权回调放行的行可见;
|
||||
// 无 ProjectID 的行无法授权(拒绝);项目/来源过滤沿用查询口径。
|
||||
func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) knowledgePred {
|
||||
return func(d core.KnowledgeDoc) bool {
|
||||
if d.Owner == "" || d.Owner == requester || d.ProjectID == "" {
|
||||
return false
|
||||
}
|
||||
if q.ProjectID != "" && d.ProjectID != q.ProjectID {
|
||||
return false
|
||||
}
|
||||
if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
|
||||
return false
|
||||
}
|
||||
return allow(d.Owner, d.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
// SearchKnowledgeShared 共享检索(EXT-8 切片 2):返回他人 owner 的项目知识中,
|
||||
// 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。
|
||||
// allow 出错由调用方前置处理——本层只见布尔。
|
||||
func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||||
pred := sharedVisible(requester, q, allow)
|
||||
if s.dialect.name() == "postgres" {
|
||||
return s.searchKnowledgePGShared(requester, q, pred)
|
||||
}
|
||||
return s.searchKnowledgeSQLiteShared(requester, q, pred)
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。
|
||||
if len(out) == 0 && q.Text != "" {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
return s.searchKnowledgeLike(owner, q, topK)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// searchKnowledgeSQLiteShared 共享版:FTS SQL 本身无 owner 预过滤(可见性原为
|
||||
// 后置判定),直接换谓词即可;LIKE 兜底换“他人行”预过滤。
|
||||
func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
||||
out, err := s.searchKnowledgeFTS(pred, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 && q.Text != "" {
|
||||
return s.searchKnowledgeLikeShared(requester, q, pred)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。
|
||||
func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
@@ -293,7 +437,7 @@ func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]co
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !knowledgeVisible(owner, q, h.Doc) {
|
||||
if !pred(h.Doc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
@@ -301,14 +445,38 @@ func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]co
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`),
|
||||
requester, "%"+q.Text+"%", "%"+q.Text+"%", topK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。
|
||||
if len(out) == 0 && q.Text != "" {
|
||||
return s.searchKnowledgeLike(owner, q, topK)
|
||||
defer rows.Close()
|
||||
out := []core.RetrievalHit{}
|
||||
for rows.Next() {
|
||||
var d core.KnowledgeDoc
|
||||
var own, ca string
|
||||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if !pred(d) {
|
||||
continue
|
||||
}
|
||||
out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"})
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
|
||||
|
||||
@@ -404,9 +404,51 @@ func init() {
|
||||
migration{version: 16, name: "secrets_context_and_variants",
|
||||
up: map[string][]string{"sqlite": v16Up, "postgres": v16Up},
|
||||
down: map[string][]string{"sqlite": v16Down, "postgres": v16Down}},
|
||||
migration{version: 17, name: "pending_notices",
|
||||
up: map[string][]string{"sqlite": v17Up, "postgres": v17Up},
|
||||
down: map[string][]string{"sqlite": v17Down, "postgres": v17Down}},
|
||||
migration{version: 18, name: "command_audit_owner",
|
||||
// 命令审计租户归属(GET /api/commands/audit 曾无 owner 过滤,跨租户可读)。
|
||||
// 存量行回填取 payload.actor——修复前行未记租户,Actor 是唯一可用线索且
|
||||
// 对历史 HTTP 入口恒等于发起租户;actor 为空的行无法归属,owner 留空,
|
||||
// API 查询不再返回(数据保留在表内,可直查数据库取证)。索引覆盖
|
||||
// owner×conversation×time 的两种查询形态。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`UPDATE command_audit SET owner = COALESCE(json_extract(payload, '$.actor'), '') WHERE owner = ''`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`UPDATE command_audit SET owner = COALESCE(payload::json->>'actor', '') WHERE owner = ''`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`,
|
||||
}},
|
||||
// down:先删引用列的索引再 DROP COLUMN(sqlite 对被索引列直接 DROP
|
||||
// 会报 no such column: owner;PG 先删同样无害)。
|
||||
down: map[string][]string{"sqlite": {
|
||||
`DROP INDEX IF EXISTS idx_audit_owner`,
|
||||
`ALTER TABLE command_audit DROP COLUMN owner`,
|
||||
}, "postgres": {
|
||||
`DROP INDEX IF EXISTS idx_audit_owner`,
|
||||
`ALTER TABLE command_audit DROP COLUMN owner`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
// v17 后台子代理完成通知的持久化队列(N2 WP5:重启后重投未投递结果)。
|
||||
var v17Up = []string{
|
||||
`CREATE TABLE IF NOT EXISTS pending_notices (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pending_notices_conv ON pending_notices(conversation_id, created_at)`,
|
||||
}
|
||||
|
||||
var v17Down = []string{`DROP TABLE IF EXISTS pending_notices`}
|
||||
|
||||
// v16 密钥独立表、设置快照、条件注入、用户命令、会话要求、消息排除与候选回复。
|
||||
var v16Up = []string{
|
||||
`CREATE TABLE IF NOT EXISTS secret_keys (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// Notice 是一条待投递的后台子代理完成通知(N2 WP5)。
|
||||
type Notice struct {
|
||||
ID string
|
||||
ConversationID string
|
||||
Owner string
|
||||
Name string
|
||||
Payload core.AgentResultPayload
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AppendNotice 落一条待投递通知(重启后可重投)。
|
||||
func (s *Store) AppendNotice(n Notice) error {
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO pending_notices (id,conversation_id,owner,name,payload,created_at) VALUES(?,?,?,?,?,?)`),
|
||||
n.ID, n.ConversationID, n.Owner, n.Name, marshalJSON(&n.Payload), nowISO())
|
||||
return err
|
||||
}
|
||||
|
||||
// ListNotices 取回全部待投递通知(Manager 启动时装入内存队列)。
|
||||
func (s *Store) ListNotices() ([]Notice, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,conversation_id,owner,name,payload,created_at FROM pending_notices ORDER BY created_at`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Notice
|
||||
for rows.Next() {
|
||||
var n Notice
|
||||
var payload, ca string
|
||||
if err := rows.Scan(&n.ID, &n.ConversationID, &n.Owner, &n.Name, &payload, &ca); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Payload, _ = unmarshalJSON[core.AgentResultPayload](payload)
|
||||
n.CreatedAt, _ = parseTime(ca)
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteNotice 投递成功后删除(按 id 幂等)。
|
||||
func (s *Store) DeleteNotice(id string) error {
|
||||
_, err := s.db.Exec(s.q(`DELETE FROM pending_notices WHERE id=?`), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteNotices 批量删除(投递成功批)。
|
||||
func (s *Store) DeleteNotices(ids []string) error {
|
||||
for _, id := range ids {
|
||||
if err := s.DeleteNotice(id); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -195,3 +195,23 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message) (bool, error) {
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// ListInterruptedSubagentRuns 列出被收成 interrupted 的子代理 run
|
||||
// (N2 WP5 重启续跑的候选集;幂等过滤由转录目录的 done/resumed 标记完成)。
|
||||
func (s *Store) ListInterruptedSubagentRuns() ([]core.Run, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT `+runColumns+` FROM runs WHERE kind=? AND status=? ORDER BY created_at`),
|
||||
string(core.RunSubagent), string(core.RunInterrupted))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []core.Run{}
|
||||
for rows.Next() {
|
||||
r, err := scanRun(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -49,3 +49,48 @@ func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.R
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// searchKnowledgePGShared EXT-8 切片 2 共享版:他人 owner 的行 + 谓词后置判定
|
||||
// (allow 回调由上层注入;排序与自检索同构)。
|
||||
func (s *Store) searchKnowledgePGShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
text := q.Text
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at,
|
||||
ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r
|
||||
FROM knowledge k
|
||||
WHERE (k.owner<>? AND k.owner<>'')
|
||||
AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?)
|
||||
ORDER BY r DESC, k.created_at DESC
|
||||
LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []core.RetrievalHit{}
|
||||
for rows.Next() {
|
||||
var d core.KnowledgeDoc
|
||||
var own, ca string
|
||||
var rank float64
|
||||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if !pred(d) {
|
||||
continue
|
||||
}
|
||||
score := rank
|
||||
if score == 0 {
|
||||
score = 0.5
|
||||
}
|
||||
out = append(out, core.RetrievalHit{Doc: d, Score: score, Kind: "fulltext"})
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 16 {
|
||||
t.Fatalf("expected versions [1..16], got %v", got)
|
||||
if len(got) != 18 {
|
||||
t.Fatalf("expected versions [1..18], got %v", got)
|
||||
}
|
||||
// 老库形态(v2 前):events 全局自增、settings 单列主键。
|
||||
if err := st.MigrateDown(1); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -61,6 +62,60 @@ func (s *Store) DeleteKnowledgeVectors(ctx context.Context, owner, ref string) e
|
||||
|
||||
// SearchKnowledgeVectors 余弦扫描:候选 = 租户本人 + 共享(owner=”),dim 匹配,
|
||||
// 再按 RetrievalQuery 的项目/来源过滤。
|
||||
// SearchKnowledgeVectorsShared EXT-8 余项:他人 owner 的向量行 + 谓词后置判定
|
||||
// (allow 由上层注入适配器;无 ProjectID 行拒绝)。结构同自检索(余弦扫描)。
|
||||
func (s *Store) SearchKnowledgeVectorsShared(ctx context.Context, requester string, vec []float32, topK int, filter core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, s.q(`
|
||||
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, v.vec
|
||||
FROM knowledge_vectors v JOIN knowledge k ON k.id = v.doc_id
|
||||
WHERE (v.owner<>? AND v.owner<>'') AND v.dim=?`), requester, len(vec))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
type cand struct {
|
||||
hit core.RetrievalHit
|
||||
sim float64
|
||||
}
|
||||
var cands []cand
|
||||
for rows.Next() {
|
||||
var d core.KnowledgeDoc
|
||||
var own, ca string
|
||||
var blob []byte
|
||||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) {
|
||||
continue
|
||||
}
|
||||
if filter.ProjectID != "" && d.ProjectID != filter.ProjectID {
|
||||
continue
|
||||
}
|
||||
if len(filter.Sources) > 0 && !containsStr(filter.Sources, d.Source) {
|
||||
continue
|
||||
}
|
||||
sim := cosine(vec, decodeVector(blob))
|
||||
cands = append(cands, cand{hit: core.RetrievalHit{Doc: d, Score: sim, Kind: "vector"}, sim: sim})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(cands, func(i, j int) bool { return cands[i].sim > cands[j].sim })
|
||||
out := []core.RetrievalHit{}
|
||||
for _, c := range cands {
|
||||
out = append(out, c.hit)
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) SearchKnowledgeVectors(ctx context.Context, owner string, vec []float32, topK int, filter core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
|
||||
+36
-1
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -26,8 +27,42 @@ const (
|
||||
execSetting = "runtime.exec"
|
||||
)
|
||||
|
||||
// execGate runtime.exec 总闸(默认开;false = 执行原语整体禁用,fail-closed)。
|
||||
// execHardOff 启动级硬关闭(组合根依启动参数设置)。runtime.exec 是用户可写的
|
||||
// 业务设置,多用户部署时任何人都能把它改回 true;硬关闭不读设置,设置无法覆盖。
|
||||
var execHardOff atomic.Bool
|
||||
|
||||
// SetExecDisabled 设置启动级硬关闭;仅组合根在装配期调用。
|
||||
func SetExecDisabled(off bool) { execHardOff.Store(off) }
|
||||
|
||||
// ExecDisabled 报告是否处于启动级硬关闭。
|
||||
func ExecDisabled() bool { return execHardOff.Load() }
|
||||
|
||||
// execToolNames 受硬关闭约束的执行原语。
|
||||
var execToolNames = map[string]bool{"run_shell": true, "run_python": true, "register_artifact": true}
|
||||
|
||||
// IsExecTool 报告工具是否属于执行原语族。
|
||||
func IsExecTool(name string) bool { return execToolNames[name] }
|
||||
|
||||
// FilterExecDisabled 硬关闭时从工具清单移除执行原语(可见性一侧;执行一侧由 execGate 拒绝)。
|
||||
func FilterExecDisabled(list []core.ToolDesc) []core.ToolDesc {
|
||||
if !ExecDisabled() {
|
||||
return list
|
||||
}
|
||||
out := make([]core.ToolDesc, 0, len(list))
|
||||
for _, t := range list {
|
||||
if !IsExecTool(t.Name) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// execGate 执行原语总闸:启动级硬关闭优先;其后 runtime.exec(默认开;false =
|
||||
// 执行原语整体禁用,fail-closed)。
|
||||
func execGate(rc ToolExecCtx) error {
|
||||
if ExecDisabled() {
|
||||
return fmt.Errorf("exec tools: 已被启动参数禁用(disableExecTools)")
|
||||
}
|
||||
if !rc.SettingBool(execSetting, true) {
|
||||
return fmt.Errorf("exec tools: 已被设置禁用(%s=false)", execSetting)
|
||||
}
|
||||
|
||||
@@ -321,8 +321,11 @@ func firstPos(vals ...int) int {
|
||||
|
||||
// InterpreterExecConfig 把产品解释器白名单换算为 agent-native 内核的
|
||||
// ExecConfig(squad 角色经 agent-native 后端执行时复用同一套约束与环境)。
|
||||
// 两个解释器都缺席时返回 nil(内核侧不注册执行工具,fail-closed)。
|
||||
// 两个解释器都缺席或启动级硬关闭时返回 nil(内核侧不注册执行工具,fail-closed)。
|
||||
func InterpreterExecConfig() *agenttools.ExecConfig {
|
||||
if ExecDisabled() {
|
||||
return nil
|
||||
}
|
||||
cfg := &agenttools.ExecConfig{Env: execEnv()}
|
||||
if p, err := exec.LookPath("bash"); err == nil {
|
||||
cfg.Bash = p
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user