Add the optional intent gate port with the squadintent adapter, CLI wiring, and pin cascade
Details (original message): Add the optional intent gate port with the squadintent adapter, CLI wiring, and pin cascade.
This commit is contained in:
@@ -10,3 +10,6 @@
|
||||
[submodule "deps/logging-system"]
|
||||
path = deps/logging-system
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
|
||||
[submodule "deps/agent-intent"]
|
||||
path = deps/agent-intent
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
- 本库只做编排与任务拆解,不实现 Agent。智能体执行只来自 Agent-Native 或调用方注册的后端。
|
||||
- 未知 backend fail-closed,禁止静默回落 stub。
|
||||
- 意图闸门为可选能力:唯 `squadintent` 与组合根(cli 接线)可 import agent-intent;`squad`/`server`/`store` 核心不感知意图。未配置意图(无闸门或零意图)时引擎行为与原实现一致;闸门错误与守卫拒绝 fail-closed(`squad.intent_refused`)。
|
||||
- 磁盘上的运行状态是恢复权威;HTTP 错误使用稳定规范码和 `X-Trace-Id`,不以自由文本作为唯一错误合同。
|
||||
- 依赖仓版本以本仓 gitlink 为准。构建时只认本仓 `go.mod` 的 `replace`;嵌套子模块里的 `replace` 不参与本仓编译。
|
||||
|
||||
|
||||
+2
-1
@@ -21,11 +21,12 @@
|
||||
| 逻辑 ID | 路径 | 角色 |
|
||||
|---|---|---|
|
||||
| agent-native | `deps/agent-native` | build-required |
|
||||
| agent-intent | `deps/agent-intent` | build-required,可选意图闸门(`squadintent` 适配器;未配置意图时行为与原实现一致) |
|
||||
| development-guidelines | `deps/development-guidelines` | development-only |
|
||||
| error-code-system | `deps/error-code-system` | build-required |
|
||||
| logging-system | `deps/logging-system` | build-required |
|
||||
|
||||
`go.mod` 的 `replace` 都指向 `./deps/`。嵌套在 `deps/agent-native/deps/` 里的同名副本不参与本仓编译,提交必须与本仓 gitlink 一致(`scripts/submodule-consistency.sh`)。
|
||||
`go.mod` 的 `replace` 都指向 `./deps/`。嵌套在 `deps/agent-native/deps/`、`deps/agent-intent/deps/` 里的同名副本不参与本仓编译,提交必须与本仓 gitlink 一致(`scripts/submodule-consistency.sh`)。
|
||||
|
||||
克隆:
|
||||
|
||||
|
||||
+30
-8
@@ -14,9 +14,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-squad/server"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/squadintent"
|
||||
"agent-squad/store"
|
||||
|
||||
"logging-system/config"
|
||||
@@ -68,6 +70,7 @@ func usage() {
|
||||
AGENT_SQUAD_LLM_BASE_URL OpenAI 兼容端点(agent-native 后端与 llm 规划器)
|
||||
AGENT_SQUAD_LLM_API_KEY API key
|
||||
AGENT_SQUAD_LLM_MODEL 默认模型
|
||||
AGENT_SQUAD_INTENT_ROOT 意图配置目录(*.json;空=不启用意图闸门,--intent-root 可覆盖)
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -88,7 +91,9 @@ func dataDir() string {
|
||||
}
|
||||
|
||||
// assemble 组装引擎;planner=llm 且未配置模型时回退 rule 并提示。
|
||||
func assemble(dir, plannerName, modelOverride string) (*app, error) {
|
||||
// intentRoot 非空时挂可选意图闸门(目录内 *.json 为意图 overlay;空目录/空配置等价未启用;
|
||||
// 配置非法 fail-closed 拒绝启动)。
|
||||
func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error) {
|
||||
st, err := store.New(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -123,9 +128,24 @@ func assemble(dir, plannerName, modelOverride string) (*app, error) {
|
||||
// 事件双层:FileEventSink 落盘(seq+脱敏,重启可审计)→ FanoutSink(SSE 实时/重放)。
|
||||
fanout := squad.NewFanoutSink(8192)
|
||||
fileSink := store.NewFileEventSink(dir, fanout)
|
||||
var gate squad.IntentGate
|
||||
if intentRoot != "" {
|
||||
g, err := squadintent.New(context.Background(), squadintent.Options{
|
||||
IntentOptions: intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
},
|
||||
FS: os.DirFS(intentRoot),
|
||||
Roots: []string{"."},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("意图配置加载失败(fail-closed):%w", err)
|
||||
}
|
||||
gate = g // g 可为 nil(来源为空),等价未启用
|
||||
}
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
|
||||
Backends: backends, Sink: fileSink,
|
||||
Backends: backends, Sink: fileSink, IntentGate: gate,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -167,10 +187,11 @@ func cmdServe(args []string) int {
|
||||
data := fs.String("data", dataDir(), "数据目录")
|
||||
planner := fs.String("planner", "rule", "规划器:rule|llm")
|
||||
model := fs.String("model", "", "默认模型(llm 规划器/后端)")
|
||||
intentRoot := fs.String("intent-root", os.Getenv("AGENT_SQUAD_INTENT_ROOT"), "意图配置目录(*.json;空=不启用意图闸门)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
a, err := assemble(*data, *planner, *model)
|
||||
a, err := assemble(*data, *planner, *model, *intentRoot)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "组装失败:", err)
|
||||
return 1
|
||||
@@ -201,7 +222,7 @@ func cmdRole(args []string) int {
|
||||
return 2
|
||||
}
|
||||
data := dataDir()
|
||||
a, err := assemble(data, "rule", "")
|
||||
a, err := assemble(data, "rule", "", "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "组装失败:", err)
|
||||
return 1
|
||||
@@ -324,7 +345,7 @@ func cmdBlueprint(args []string) int {
|
||||
fmt.Println("用法: agent-squad blueprint list|add|rm ...")
|
||||
return 2
|
||||
}
|
||||
a, err := assemble(dataDir(), "rule", "")
|
||||
a, err := assemble(dataDir(), "rule", "", "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "组装失败:", err)
|
||||
return 1
|
||||
@@ -388,8 +409,9 @@ func cmdRun(args []string) int {
|
||||
model := fs.String("model", "", "默认模型")
|
||||
timeout := fs.Duration("timeout", 30*time.Minute, "总超时")
|
||||
mode := fs.String("mode", "standard", "编排模式:standard|leader(leader 委派循环)")
|
||||
leaderRole := fs.String("leader-role", "", "leader 模式的 leader 角色 key")
|
||||
leaderRole := fs.String("leader-role", "", "leader 模式下的 leader 角色 key")
|
||||
maxTurns := fs.Int("max-leader-turns", 8, "leader 模式轮次上限")
|
||||
intentRoot := fs.String("intent-root", os.Getenv("AGENT_SQUAD_INTENT_ROOT"), "意图配置目录(*.json;空=不启用意图闸门)")
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return 2
|
||||
}
|
||||
@@ -398,7 +420,7 @@ func cmdRun(args []string) int {
|
||||
fmt.Println("用法: agent-squad run \"目标文本\" [flags]")
|
||||
return 2
|
||||
}
|
||||
a, err := assemble(dataDir(), *planner, *model)
|
||||
a, err := assemble(dataDir(), *planner, *model, *intentRoot)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "组装失败:", err)
|
||||
return 1
|
||||
@@ -521,7 +543,7 @@ func cmdRuns(args []string) int {
|
||||
fmt.Println("用法: agent-squad runs list|inspect|pending|respond|blackboard ...")
|
||||
return 2
|
||||
}
|
||||
a, err := assemble(dataDir(), "rule", "")
|
||||
a, err := assemble(dataDir(), "rule", "", "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "组装失败:", err)
|
||||
return 1
|
||||
|
||||
+1
Submodule deps/agent-intent added at 55780ae2cc
Vendored
+1
-1
Submodule deps/agent-native updated: cc8a6a7702...c659dc17d5
Vendored
+1
-1
Submodule deps/error-code-system updated: 95a03b7578...dbeed2f1b6
@@ -0,0 +1,36 @@
|
||||
# Agent-Squad 接入 agent-intent 意图闸门(可选能力)
|
||||
|
||||
对应设计:Agent-Intent 仓 `docs/records/2026-09-29-decoupling-and-llm-layering.md`(第三节 a,决策 D1/D2/D4/D5 按推荐执行)。跨仓总测试报告:Agent-Intent 仓 `docs/records/2026-09-29-squad-native-integration-report.md`。
|
||||
|
||||
## 形态与边界
|
||||
|
||||
- `squad/intent.go`:`IntentGate` 窄端口 + `IntentDecision`(Reject / BlueprintKey / nil=透传),`Options.IntentGate`,`Run()` 入口一次裁决(澄清续跑不重复过闸;显式 TaskKey 优先)。约 60 行,零外部依赖。
|
||||
- `squadintent/`:**全仓唯一 import agent-intent 处**。`New` 无来源返回 nil 闸门(= 未启用);分支映射 GuardHit→Reject、Matched+`metadata.blueprint`→蓝图定向、其余→nil;识别错误 fail-closed。
|
||||
- cli:`--intent-root` flag + `AGENT_SQUAD_INTENT_ROOT` env(run/serve);配置非法 fail-closed 拒绝启动。
|
||||
- 错误码:守卫拒绝 `squad.intent_refused`;意图定向的蓝图缺失 `squad.blueprint_not_found`(复用)。
|
||||
- 运行审计:`state.json` 记录 `intent_id` / `intent_blueprint`,`task_key` 与 `planning_mode` 随定向变化。
|
||||
|
||||
## 解耦证明
|
||||
|
||||
1. 结构:`GOWORK=off go list -deps ./squad ./server ./store` 不含 agent-intent(0 命中)。
|
||||
2. 零配置等价:`squad.TestIntentGateNilDecisionKeepsOriginalPath`(闸门恒不干预 ⇒ 与无闸门引擎结果一致);`squadintent.TestNewWithEmptyOverlayActsAsDisabled`(来源存在但零内容 ⇒ Decide 恒 nil);`squadintent.TestEngineWithoutGateKeepsOriginalBehavior`。
|
||||
3. 等价回归:既有 `conformance` 全链路套件在本次改动后原样通过。
|
||||
4. 一致性:`scripts/submodule-consistency.sh` 扩展为对 `deps/agent-native` 与 `deps/agent-intent` 两处嵌套副本的 8 项比对,全部 ok。
|
||||
|
||||
## 验证证据(2026-09-29,go vet/test/-race 全绿)
|
||||
|
||||
| 层 | 用例 | 期望与结果 |
|
||||
|---|---|---|
|
||||
| 引擎 | TestIntentGateBlueprintRouting | 无闸门=自主规划;有闸门=定向到模板步骤 b_intent ✅ |
|
||||
| 引擎 | TestIntentGateRejectFailsWithStableCode | RunFailed + `squad.intent_refused` ✅ |
|
||||
| 引擎 | TestIntentGateUnknownBlueprintFailsClosed | RunFailed + `squad.blueprint_not_found` ✅ |
|
||||
| 引擎 | TestIntentGateErrorFailsClosed | 闸门错误 ⇒ 失败运行 ✅ |
|
||||
| 引擎 | TestIntentGateExplicitTaskKeyBypassesGate | TaskKey 时闸门零调用 ✅ |
|
||||
| 适配器 | TestDecideGuardHitRejectsWithGuidance / RouteToBlueprint / AmbiguousFallbackPassThrough / ExplicitTaskKeyWins / NewWithoutSourcesNil / EmptyOverlayDisabled / BadConfigFailClosed | 全通过 ✅ |
|
||||
| 集成 | TestEngineEndToEndWithAdapterGate | 识别→决策→蓝图→规划→脚本后端执行→质检完成;守卫输入拒绝 ✅(`-race` 同过) |
|
||||
|
||||
E2E(真实 CLI,输出摘录见总报告):同一守卫文本,外挂意图→`squad.intent_refused` 拒绝;无意图→自主规划跑完。同一不含关键词的目标,外挂意图→1 步模板计划(`intent_id`/`intent_blueprint` 落盘);无意图→4 步自主计划。
|
||||
|
||||
## 回退程序
|
||||
|
||||
删 `squadintent/`、`squad/intent.go`、cli 接线(assemble 的 intentRoot 参数与闸门构造、run/serve 的 flag、usage 两行)、go.mod 的 agent-intent require/replace、`.gitmodules` 条目与 submodule、一致性脚本两段。`squad` 引擎除 Options 一个可选字段与 Run() 入口一个 nil 可跳过的块外无其他改动。
|
||||
@@ -3,11 +3,14 @@ module agent-squad
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0
|
||||
agent-native v0.0.0
|
||||
error-code-system v0.0.0
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
replace agent-intent => ./deps/agent-intent
|
||||
|
||||
replace agent-native => ./deps/agent-native
|
||||
|
||||
replace error-code-system => ./deps/error-code-system
|
||||
|
||||
@@ -39,3 +39,10 @@
|
||||
- `deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
|
||||
- 执行方式为 manual。
|
||||
- 回退该规范挂载时,同时确认 Agent-Native 子模块路径是否已迁移;只还原规范 gitlink 不会把 `deps/agent-native` 移回根目录。
|
||||
|
||||
### 2026-09-29 追加:agent-intent 可选意图闸门
|
||||
|
||||
- 新增 `deps/agent-intent`(固定 `55780ae2cce4ae98d8b0557b8234af8833a7c522`);`deps/error-code-system` 前进到 `dbeed2f1b67eb417ce089e7033a228578fbbeb03`(`squad.intent_refused`,追加式);`deps/agent-native` 前进到 `c659dc17d5086a8128999011994fc2dbe73419d3`(其仓内新增 intentgate 装饰器)。
|
||||
- 依赖边界新增:唯 `squadintent` 与 cli 组合根可 import agent-intent;`squad`/`server`/`store` 不感知意图;未配置意图时引擎行为与原实现一致。
|
||||
- 接入与验证记录见 [docs/records/2026-09-29-intent-gate-adoption.md](../docs/records/2026-09-29-intent-gate-adoption.md);跨仓测试报告见 Agent-Intent 仓 docs/records。
|
||||
- 回退:删 `squadintent/` 与 cli 接线、go.mod 两行、submodule 与 `.gitmodules` 条目、一致性脚本对应段;可将 agent-native 回退到 `cc8a6a7`(其 intentgate 为可选包,不回退亦可编译)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# 比较本仓 deps 与嵌套在 deps/agent-native 里的同名子模块 gitlink。
|
||||
# 编译只用本仓 go.mod 的 replace;这里防止两份副本锁到不同提交。
|
||||
# 比较本仓 deps 与嵌套在 deps/agent-native、deps/agent-intent 里的同名子模块 gitlink。
|
||||
# 编译只用本仓 go.mod 的 replace;这里防止多份副本锁到不同提交。
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
fail=0
|
||||
@@ -8,9 +8,10 @@ fail=0
|
||||
compare() {
|
||||
label=$1
|
||||
outer=$2
|
||||
inner=$3
|
||||
parent=$3
|
||||
inner=$4
|
||||
recorded=$(git rev-parse ":$outer")
|
||||
nested=$(git -C deps/agent-native rev-parse "HEAD:$inner")
|
||||
nested=$(git -C "$parent" rev-parse "HEAD:$inner")
|
||||
if [ "$recorded" != "$nested" ]; then
|
||||
echo "mismatch $label outer=$recorded nested=$nested" >&2
|
||||
fail=1
|
||||
@@ -19,17 +20,21 @@ compare() {
|
||||
fi
|
||||
}
|
||||
|
||||
compare development-guidelines deps/development-guidelines deps/development-guidelines
|
||||
compare error-code-system deps/error-code-system deps/error-code-system
|
||||
compare logging-system deps/logging-system deps/logging-system
|
||||
for parent in deps/agent-native deps/agent-intent; do
|
||||
compare "development-guidelines@$parent" deps/development-guidelines "$parent" deps/development-guidelines
|
||||
compare "error-code-system@$parent" deps/error-code-system "$parent" deps/error-code-system
|
||||
compare "logging-system@$parent" deps/logging-system "$parent" deps/logging-system
|
||||
done
|
||||
|
||||
recorded=$(git rev-parse :deps/agent-native)
|
||||
actual=$(git -C deps/agent-native rev-parse HEAD)
|
||||
if [ "$recorded" != "$actual" ]; then
|
||||
echo "mismatch agent-native recorded=$recorded HEAD=$actual" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "ok agent-native $recorded"
|
||||
fi
|
||||
for path in deps/agent-native deps/agent-intent; do
|
||||
recorded=$(git rev-parse ":$path")
|
||||
actual=$(git -C "$path" rev-parse HEAD)
|
||||
if [ "$recorded" != "$actual" ]; then
|
||||
echo "mismatch $path recorded=$recorded HEAD=$actual" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "ok $path $recorded"
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$fail"
|
||||
|
||||
+42
-5
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"error-code-system/errcode"
|
||||
)
|
||||
|
||||
// Options 引擎组装选项(组合根装配;库内零产品依赖)。
|
||||
@@ -32,6 +34,9 @@ type Options struct {
|
||||
HitlPolicy HitlPolicy
|
||||
// RepairBudget 质检失败自动修复次数(默认 1)。
|
||||
RepairBudget int
|
||||
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
||||
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
||||
IntentGate IntentGate
|
||||
}
|
||||
|
||||
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
|
||||
@@ -148,8 +153,8 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
|
||||
|
||||
meta := RunMetadata{
|
||||
ID: runID, Goal: goal, Status: string(RunPlanning),
|
||||
Backend: firstNonEmpty(runOpts.Backend, e.opts.DefaultBackend),
|
||||
Planner: e.opts.Planner.Name(),
|
||||
Backend: firstNonEmpty(runOpts.Backend, e.opts.DefaultBackend),
|
||||
Planner: e.opts.Planner.Name(),
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"task_key": runOpts.TaskKey,
|
||||
@@ -163,6 +168,38 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
|
||||
}
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
|
||||
|
||||
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
|
||||
// nil 决策 = 不干预走原路径;闸门系统错误与守卫拒绝同样 fail-closed 成失败运行。
|
||||
if e.opts.IntentGate != nil && runOpts.TaskKey == "" {
|
||||
dec, derr := e.opts.IntentGate.Decide(ctx, goal, runOpts)
|
||||
if derr != nil {
|
||||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: derr.Error(), At: time.Now()})
|
||||
return e.failNew(&meta, derr)
|
||||
}
|
||||
if dec != nil {
|
||||
if rej := dec.Reject; rej != nil {
|
||||
if dec.IntentID != "" {
|
||||
meta.Metadata["intent_id"] = dec.IntentID
|
||||
}
|
||||
err := errcode.New(errcode.SquadIntentRefused, rej.Reason, "guidance", rej.Guidance)
|
||||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: err.Error(), At: time.Now()})
|
||||
return e.failNew(&meta, err)
|
||||
}
|
||||
if dec.BlueprintKey != "" {
|
||||
if _, ok := e.opts.Blueprints.Get(dec.BlueprintKey); !ok {
|
||||
err := errcode.New(errcode.SquadBlueprintNotFound,
|
||||
fmt.Sprintf("意图 %s 定向的蓝图 %s 不存在", dec.IntentID, dec.BlueprintKey))
|
||||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: err.Error(), At: time.Now()})
|
||||
return e.failNew(&meta, err)
|
||||
}
|
||||
runOpts.TaskKey = dec.BlueprintKey
|
||||
meta.Metadata["intent_id"] = dec.IntentID
|
||||
meta.Metadata["intent_blueprint"] = dec.BlueprintKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
|
||||
return e.planAndExecute(ctx, st, runOpts)
|
||||
}
|
||||
@@ -194,9 +231,9 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
}
|
||||
req := &HumanRequest{
|
||||
ID: newRequestID(), RunID: meta.ID, Kind: HitlClarification, Severity: SeverityActionRequired,
|
||||
Question: "规划器无法确定如何执行该目标,请补充信息:",
|
||||
Options: opts,
|
||||
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
|
||||
Question: "规划器无法确定如何执行该目标,请补充信息:",
|
||||
Options: opts,
|
||||
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package squad
|
||||
|
||||
import "context"
|
||||
|
||||
// IntentRejection 守卫拦截:拒绝该次运行并给出引导("识别即拒绝"语义)。
|
||||
type IntentRejection struct {
|
||||
// Reason 稳定拒因(进入运行结果与事件文本)。
|
||||
Reason string
|
||||
// Guidance 给用户的引导文案(进入运行结果错误详情)。
|
||||
Guidance string
|
||||
}
|
||||
|
||||
// IntentDecision 意图闸门的分流决策;nil 表示不干预(完全走原路径)。
|
||||
type IntentDecision struct {
|
||||
// Reject 非空时拒绝该次运行(守卫拦截)。
|
||||
Reject *IntentRejection
|
||||
// BlueprintKey 非空时定向使用该蓝图模板(等价显式 TaskKey;不存在的蓝图 fail-closed)。
|
||||
BlueprintKey string
|
||||
// IntentID / Handler / Metadata 透传识别元数据,供运行审计与日志。
|
||||
IntentID string
|
||||
Handler string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// IntentGate 可选意图闸门:在 Run 入口对目标文本做一次确定性识别。
|
||||
// 入口语义:澄清/确认后的续跑不再过闸;显式 TaskKey 优先于意图定向(引擎保证)。
|
||||
// 实现必须无副作用、可并发调用;系统错误以 error 返回(引擎 fail-closed 标记运行失败)。
|
||||
// 宿主实现见 squadintent 适配器;本接口不依赖 agent-intent。
|
||||
type IntentGate interface {
|
||||
Decide(ctx context.Context, goal Goal, opts RunOptions) (*IntentDecision, error)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubGate 引擎级测试用闸门桩:squad 包测试不得 import agent-intent(结构解耦纪律)。
|
||||
type stubGate struct {
|
||||
dec *IntentDecision
|
||||
err error
|
||||
calls int
|
||||
lastText string
|
||||
}
|
||||
|
||||
func (g *stubGate) Decide(ctx context.Context, goal Goal, opts RunOptions) (*IntentDecision, error) {
|
||||
g.calls++
|
||||
g.lastText = goal.Text
|
||||
return g.dec, g.err
|
||||
}
|
||||
|
||||
func gateTestBlueprint(key string, match []string, stepID string) Blueprint {
|
||||
return Blueprint{
|
||||
Key: key, Title: key, Match: match,
|
||||
Steps: []StepSpec{{ID: stepID, Role: "researcher", Title: "按模板执行", Targets: []string{"output.md"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func newGateTestEngine(t *testing.T, gate IntentGate, bps ...Blueprint) *SquadEngine {
|
||||
t.Helper()
|
||||
engine, _ := newTestEngine(t, NewRulePlanner(),
|
||||
map[string]map[string]string{"01_researcher": {"output.md": goodReport}}, nil)
|
||||
for _, bp := range bps {
|
||||
if err := engine.Blueprints().Register(bp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine.opts.IntentGate = gate // 测试直改;产品侧经 Options 组装
|
||||
return engine
|
||||
}
|
||||
|
||||
func runGoal(t *testing.T, e *SquadEngine, text, taskKey string) *RunResult {
|
||||
t.Helper()
|
||||
res, err := e.Run(context.Background(), Goal{Text: text, HitlMode: HitlAuto}, RunOptions{TaskKey: taskKey})
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// T0:闸门存在但恒不干预时,运行结果与无闸门引擎一致(等价回归)。
|
||||
func TestIntentGateNilDecisionKeepsOriginalPath(t *testing.T) {
|
||||
bp := gateTestBlueprint("kw_report", []string{"调研报告"}, "b1")
|
||||
plain := newGateTestEngine(t, nil, bp)
|
||||
gated := newGateTestEngine(t, &stubGate{}, bp)
|
||||
for _, text := range []string{"帮我做一份调研报告", "随便写点什么"} {
|
||||
a, b := runGoal(t, plain, text, ""), runGoal(t, gated, text, "")
|
||||
if a.Status != b.Status {
|
||||
t.Fatalf("status differs for %q: %s vs %s", text, a.Status, b.Status)
|
||||
}
|
||||
if (a.Plan == nil) != (b.Plan == nil) || len(a.Plan.Steps) != len(b.Plan.Steps) {
|
||||
t.Fatalf("plan shape differs for %q: %+v vs %+v", text, a.Plan, b.Plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 意图定向:目标文本不含蓝图关键词,无闸门走自主规划;有闸门定向到模板步骤。
|
||||
func TestIntentGateBlueprintRouting(t *testing.T) {
|
||||
bp := gateTestBlueprint("competitor_report", []string{"竞品分析报告"}, "b_intent")
|
||||
plain := newGateTestEngine(t, nil, bp)
|
||||
gated := newGateTestEngine(t, &stubGate{dec: &IntentDecision{
|
||||
BlueprintKey: "competitor_report", IntentID: "demo.report.competitor",
|
||||
}}, bp)
|
||||
text := "给我做一份竞品情况的分析报告" // 不含关键词"竞品分析报告"
|
||||
|
||||
plainRes := runGoal(t, plain, text, "")
|
||||
if plainRes.Plan == nil || len(plainRes.Plan.Steps) == 0 || plainRes.Plan.Steps[0].ID == "b_intent" {
|
||||
t.Fatalf("without gate expected autonomous plan, got %+v", plainRes.Plan)
|
||||
}
|
||||
|
||||
gatedRes := runGoal(t, gated, text, "")
|
||||
if gatedRes.Plan == nil || len(gatedRes.Plan.Steps) != 1 || gatedRes.Plan.Steps[0].ID != "b_intent" {
|
||||
t.Fatalf("with gate expected blueprint plan, got %+v", gatedRes.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
// 守卫拒绝:稳定码 squad.intent_refused,运行标记失败。
|
||||
func TestIntentGateRejectFailsWithStableCode(t *testing.T) {
|
||||
gated := newGateTestEngine(t, &stubGate{dec: &IntentDecision{
|
||||
Reject: &IntentRejection{Reason: "命中守卫意图 guard.config_change", Guidance: "请到管理界面修改"},
|
||||
IntentID: "guard.config_change",
|
||||
}})
|
||||
res := runGoal(t, gated, "帮我把系统网关地址改了", "")
|
||||
if res.Status != RunFailed {
|
||||
t.Fatalf("expected failed run, got %s", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Error, "squad.intent_refused") {
|
||||
t.Fatalf("error missing stable code: %s", res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 意图定向到不存在的蓝图:fail-closed(同未知 backend 先例)。
|
||||
func TestIntentGateUnknownBlueprintFailsClosed(t *testing.T) {
|
||||
gated := newGateTestEngine(t, &stubGate{dec: &IntentDecision{BlueprintKey: "missing_bp", IntentID: "demo.x"}})
|
||||
res := runGoal(t, gated, "随便什么目标", "")
|
||||
if res.Status != RunFailed || !strings.Contains(res.Error, "squad.blueprint_not_found") {
|
||||
t.Fatalf("expected fail-closed blueprint error, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 闸门系统错误:fail-closed 成失败运行,不静默放行。
|
||||
func TestIntentGateErrorFailsClosed(t *testing.T) {
|
||||
gated := newGateTestEngine(t, &stubGate{err: errf("recognizer down")})
|
||||
res := runGoal(t, gated, "随便什么目标", "")
|
||||
if res.Status != RunFailed || !strings.Contains(res.Error, "recognizer down") {
|
||||
t.Fatalf("expected failed run, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 TaskKey 优先:闸门不被调用。
|
||||
func TestIntentGateExplicitTaskKeyBypassesGate(t *testing.T) {
|
||||
bpA := gateTestBlueprint("competitor_report", nil, "b_intent")
|
||||
bpB := gateTestBlueprint("explicit_bp", nil, "b_explicit")
|
||||
gate := &stubGate{dec: &IntentDecision{BlueprintKey: "competitor_report"}}
|
||||
gated := newGateTestEngine(t, gate, bpA, bpB)
|
||||
res := runGoal(t, gated, "任意目标", "explicit_bp")
|
||||
if gate.calls != 0 {
|
||||
t.Fatalf("gate must not fire on explicit task key, calls=%d", gate.calls)
|
||||
}
|
||||
if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].ID != "b_explicit" {
|
||||
t.Fatalf("expected explicit blueprint, got %+v", res.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func errf(s string) error { return &gateErr{msg: s} }
|
||||
|
||||
type gateErr struct{ msg string }
|
||||
|
||||
func (e *gateErr) Error() string { return e.msg }
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package squadintent 把 agent-intent 的识别结果适配为 SquadEngine 的意图闸门。
|
||||
//
|
||||
// 本包是 Agent-Squad 仓内唯一 import agent-intent 的地方;squad/server/store 核心
|
||||
// 不感知意图。未提供任何意图来源时 New 返回 nil 闸门(= 未启用),引擎行为与
|
||||
// 未接入时完全一致;来源存在但配置非法时返回错误(调用方 fail-closed 拒绝启动)。
|
||||
//
|
||||
// 分支映射:
|
||||
//
|
||||
// GuardHit → Reject(引导文案取 Metadata["guidance"])
|
||||
// Matched/ExpectationHit 且 Metadata["blueprint"] 非空 → 蓝图定向(等价显式 TaskKey)
|
||||
// Matched 但无 blueprint 映射 / Ambiguous / Fallback → nil(不干预,走原路径)
|
||||
// 识别系统错误 → error(引擎标记运行失败,fail-closed)
|
||||
package squadintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// Options 闸门构造选项。
|
||||
type Options struct {
|
||||
// IntentOptions 传给 agent-intent:GuardDomains/ProtectedDomains/上限等(Log 可选)。
|
||||
IntentOptions intent.Options
|
||||
// GuardSpecs 宿主内置守卫 spec,应位于 ProtectedDomains 声明的域内(overlay 不可覆盖)。
|
||||
GuardSpecs []intent.IntentSpec
|
||||
// FS 与 Roots 是 JSON overlay 来源;两者为空则只用 GuardSpecs。
|
||||
FS fs.FS
|
||||
Roots []string
|
||||
}
|
||||
|
||||
// New 构造闸门。没有任何意图来源时返回 (nil, nil)——引擎拿到 nil IntentGate,
|
||||
// 行为与原实现一致(T0 行为解耦)。
|
||||
func New(ctx context.Context, o Options) (*Gate, error) {
|
||||
if len(o.GuardSpecs) == 0 && (o.FS == nil || len(o.Roots) == 0) {
|
||||
return nil, nil
|
||||
}
|
||||
b := intent.NewRegistryBuilder(o.IntentOptions)
|
||||
if len(o.GuardSpecs) > 0 {
|
||||
if err := b.Register(o.GuardSpecs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var (
|
||||
reg *intent.Registry
|
||||
err error
|
||||
)
|
||||
if o.FS != nil {
|
||||
reg, err = b.BuildFS(ctx, o.FS, o.Roots...)
|
||||
} else {
|
||||
reg, err = b.Build()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), snap: reg.Snapshot()}, nil
|
||||
}
|
||||
|
||||
// Gate 实现 squad.IntentGate。
|
||||
type Gate struct {
|
||||
rec intent.Recognizer
|
||||
snap *intent.Snapshot
|
||||
}
|
||||
|
||||
// Snapshot 暴露当前识别快照(诊断/审计用;Reload 由宿主经重建闸门实现)。
|
||||
func (g *Gate) Snapshot() *intent.Snapshot {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return g.snap
|
||||
}
|
||||
|
||||
// Decide 实现 squad.IntentGate。同输入 + 同快照结果确定;无副作用。
|
||||
func (g *Gate) Decide(ctx context.Context, goal squad.Goal, opts squad.RunOptions) (*squad.IntentDecision, error) {
|
||||
if g == nil || g.snap == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if opts.TaskKey != "" {
|
||||
return nil, nil // 显式 TaskKey 优先(引擎侧同样保证,双保险)
|
||||
}
|
||||
res, err := g.rec.Recognize(ctx, goal.Text, g.snap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch res.Branch {
|
||||
case intent.BranchGuardHit:
|
||||
if res.Top == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &squad.IntentDecision{
|
||||
Reject: &squad.IntentRejection{
|
||||
Reason: "命中守卫意图 " + res.Top.ID,
|
||||
Guidance: res.Top.Metadata["guidance"],
|
||||
},
|
||||
IntentID: res.Top.ID,
|
||||
Handler: res.Top.Handler,
|
||||
Metadata: res.Top.Metadata,
|
||||
}, nil
|
||||
case intent.BranchMatched, intent.BranchExpectationHit:
|
||||
if res.Top == nil || res.Top.Metadata["blueprint"] == "" {
|
||||
return nil, nil // 命中但无蓝图映射:不干预
|
||||
}
|
||||
return &squad.IntentDecision{
|
||||
BlueprintKey: res.Top.Metadata["blueprint"],
|
||||
IntentID: res.Top.ID,
|
||||
Handler: res.Top.Handler,
|
||||
Metadata: res.Top.Metadata,
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil // Ambiguous / Fallback:交给原路径
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package squadintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func gateOptions() Options {
|
||||
return Options{
|
||||
IntentOptions: intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
},
|
||||
GuardSpecs: []intent.IntentSpec{{
|
||||
ID: "guard.config_change", Domain: "guard", Version: 1,
|
||||
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关", "改密码"}},
|
||||
Risk: intent.RiskHigh,
|
||||
Metadata: map[string]string{
|
||||
"guidance": "系统配置请到管理界面修改,或联系管理员",
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// overlay:一条可路由意图(metadata.blueprint 定向)+ 一对歧义意图。
|
||||
func overlayFS() fstest.MapFS {
|
||||
return fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{
|
||||
"intents": [
|
||||
{"id": "demo.report.competitor", "domain": "demo", "version": 1,
|
||||
"description": "竞品分析报告",
|
||||
"matcher": {"includes_any": ["竞品", "对标分析"]},
|
||||
"confidence": {"base": 0.8, "threshold": 0.7},
|
||||
"handler": "report.competitor",
|
||||
"metadata": {"blueprint": "competitor_report"}},
|
||||
{"id": "demo.greet.hello", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}},
|
||||
{"id": "demo.greet.hi", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}}
|
||||
]
|
||||
}`)}}
|
||||
}
|
||||
|
||||
func newGate(t *testing.T) *Gate {
|
||||
t.Helper()
|
||||
g, err := New(context.Background(), func() Options {
|
||||
o := gateOptions()
|
||||
o.FS = overlayFS()
|
||||
o.Roots = []string{"."}
|
||||
return o
|
||||
}())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func decide(t *testing.T, g *Gate, text string, taskKey string) (*squad.IntentDecision, error) {
|
||||
t.Helper()
|
||||
return g.Decide(context.Background(), squad.Goal{Text: text}, squad.RunOptions{TaskKey: taskKey})
|
||||
}
|
||||
|
||||
func TestDecideGuardHitRejectsWithGuidance(t *testing.T) {
|
||||
g := newGate(t)
|
||||
dec, err := decide(t, g, "帮我把系统网关地址改了", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dec == nil || dec.Reject == nil {
|
||||
t.Fatalf("expected rejection, got %+v", dec)
|
||||
}
|
||||
if dec.IntentID != "guard.config_change" {
|
||||
t.Fatalf("intent id: %s", dec.IntentID)
|
||||
}
|
||||
if dec.Reject.Guidance == "" {
|
||||
t.Fatal("guidance missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideMatchedRoutesToBlueprint(t *testing.T) {
|
||||
g := newGate(t)
|
||||
dec, err := decide(t, g, "给我做一份竞品情况的分析报告", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dec == nil || dec.BlueprintKey != "competitor_report" {
|
||||
t.Fatalf("expected blueprint routing, got %+v", dec)
|
||||
}
|
||||
if dec.IntentID != "demo.report.competitor" || dec.Handler != "report.competitor" {
|
||||
t.Fatalf("metadata not carried: %+v", dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideAmbiguousAndFallbackPassThrough(t *testing.T) {
|
||||
g := newGate(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"ambiguous", "你好呀"},
|
||||
{"fallback", "帮我写一份周报"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dec, err := decide(t, g, tc.input, "")
|
||||
if err != nil || dec != nil {
|
||||
t.Fatalf("expected nil decision (passthrough), got %+v %v", dec, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideExplicitTaskKeyWins(t *testing.T) {
|
||||
g := newGate(t)
|
||||
dec, err := decide(t, g, "帮我把系统网关地址改了", "explicit_bp")
|
||||
if err != nil || dec != nil {
|
||||
t.Fatalf("explicit task key must bypass gate, got %+v %v", dec, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithoutSourcesReturnsNilGate(t *testing.T) {
|
||||
g, err := New(context.Background(), Options{IntentOptions: intent.Options{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g != nil {
|
||||
t.Fatal("expected nil gate without any intent source")
|
||||
}
|
||||
// nil 闸门 Decide 恒为 nil(引擎行为与原实现一致)。
|
||||
dec, err := g.Decide(context.Background(), squad.Goal{Text: "任意"}, squad.RunOptions{})
|
||||
if err != nil || dec != nil {
|
||||
t.Fatalf("nil gate decide: %+v %v", dec, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithEmptyOverlayActsAsDisabled(t *testing.T) {
|
||||
// T0 第二条路径:提供了来源但总内容为零(无内置守卫 + 空 overlay)→ Decide 恒 nil。
|
||||
// 注意:若宿主提供了 GuardSpecs,内置守卫始终生效——内置内容是独立于 overlay 的来源。
|
||||
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents": []}`)}}
|
||||
g, err := New(context.Background(), Options{
|
||||
IntentOptions: intent.Options{GuardDomains: []string{"guard"}},
|
||||
FS: fsys,
|
||||
Roots: []string{"."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g == nil {
|
||||
t.Fatal("gate expected (source provided)")
|
||||
}
|
||||
for _, input := range []string{"帮我把系统网关地址改了", "竞品分析", "你好"} {
|
||||
dec, err := decide(t, g, input, "")
|
||||
if err != nil || dec != nil {
|
||||
t.Fatalf("empty content must never intervene: %q → %+v %v", input, dec, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsClosedOnBadConfig(t *testing.T) {
|
||||
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents": [{"id": "demo.x", "domain": "demo", "version": 1, "matcher": {}}]}`)}}
|
||||
o := gateOptions()
|
||||
o.FS = fsys
|
||||
o.Roots = []string{"."}
|
||||
_, err := New(context.Background(), o)
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error on invalid config")
|
||||
}
|
||||
var ie *intent.Error
|
||||
if !errors.As(err, &ie) || ie.Code != intent.InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package squadintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// integrationScriptBackend 脚本回放后端(与 squad 测试同构,走 agent-native exec 端口)。
|
||||
type integrationScriptBackend struct{ content string }
|
||||
|
||||
func (b *integrationScriptBackend) Name() string { return "script" }
|
||||
|
||||
func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
patches := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
out := filepath.Join(req.WorkDir, t)
|
||||
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
||||
if err := os.WriteFile(out, []byte(b.content), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches[t] = []byte(b.content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "script"}, nil
|
||||
}
|
||||
|
||||
const intentReport = "# 竞品分析报告\n\n## 概述\n\n由意图定向的蓝图模板生成的交付物,长度满足最小质检要求。\n\n## 结论\n\n符合预期。\n"
|
||||
|
||||
// TestEngineEndToEndWithAdapterGate 全链路:agent-intent 识别 → 闸门决策 →
|
||||
// 引擎蓝图定向 → 规划 → 脚本后端执行(agent-native exec 端口)→ 质检完成。
|
||||
// 目标文本不含蓝图关键词,证明路由变化来自意图而非关键词匹配。
|
||||
func TestEngineEndToEndWithAdapterGate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := squad.NewRoleRegistry()
|
||||
if err := reg.Register(squad.RoleSpec{Role: "researcher", Name: "调研", Backend: "script"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bps := squad.NewBlueprintRegistry()
|
||||
if err := bps.Register(squad.Blueprint{
|
||||
Key: "competitor_report", Title: "竞品分析报告", Match: []string{"竞品分析报告"},
|
||||
Steps: []squad.StepSpec{{ID: "b1", Role: "researcher", Title: "调研并成文", Targets: []string{"output.md"}}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backends := squad.NewBackendResolver()
|
||||
backends.RegisterInstance("script", &integrationScriptBackend{content: intentReport})
|
||||
|
||||
o := gateOptions()
|
||||
o.FS = overlayFS()
|
||||
o.Roots = []string{"."}
|
||||
gate, err := New(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: bps, Planner: squad.NewRulePlanner(),
|
||||
Backends: backends, IntentGate: gate,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := engine.Run(context.Background(),
|
||||
squad.Goal{Text: "给我做一份竞品情况的分析报告", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != squad.RunCompleted {
|
||||
t.Fatalf("expected completed run, got %s (err=%s plan=%+v)", res.Status, res.Error, res.Plan)
|
||||
}
|
||||
if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].ID != "b1" {
|
||||
t.Fatalf("expected blueprint-driven plan, got %+v", res.Plan)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md")); err != nil || !strings.Contains(string(data), "竞品分析报告") {
|
||||
t.Fatalf("deliverable not produced: %v", err)
|
||||
}
|
||||
|
||||
// 守卫输入走同一引擎:拒绝且不产生工作区执行。
|
||||
res2, err := engine.Run(context.Background(),
|
||||
squad.Goal{Text: "帮我把系统网关地址改一下", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.Status != squad.RunFailed || !strings.Contains(res2.Error, "squad.intent_refused") {
|
||||
t.Fatalf("expected guard refusal, got %+v", res2)
|
||||
}
|
||||
}
|
||||
|
||||
// T0:同屏对照——同一引擎配置换成 nil 闸门,守卫文本不再被拒绝、自由目标不再定向。
|
||||
func TestEngineWithoutGateKeepsOriginalBehavior(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := squad.NewRoleRegistry()
|
||||
if err := reg.Register(squad.RoleSpec{Role: "researcher", Name: "调研", Backend: "script"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bps := squad.NewBlueprintRegistry()
|
||||
if err := bps.Register(squad.Blueprint{
|
||||
Key: "competitor_report", Title: "竞品分析报告", Match: []string{"竞品分析报告"},
|
||||
Steps: []squad.StepSpec{{ID: "b1", Role: "researcher", Title: "调研并成文", Targets: []string{"output.md"}}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backends := squad.NewBackendResolver()
|
||||
backends.RegisterInstance("script", &integrationScriptBackend{content: intentReport})
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: bps, Planner: squad.NewRulePlanner(), Backends: backends,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 无闸门:守卫文本不再被拒绝(正常自主规划路径)。
|
||||
res, err := engine.Run(context.Background(),
|
||||
squad.Goal{Text: "帮我把系统网关地址改一下", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status == squad.RunFailed && strings.Contains(res.Error, "squad.intent_refused") {
|
||||
t.Fatalf("nil gate must not refuse: %+v", res)
|
||||
}
|
||||
// 无闸门:不含关键词的目标不定向模板。
|
||||
res2, _ := engine.Run(context.Background(),
|
||||
squad.Goal{Text: "给我做一份竞品情况的分析报告", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
||||
if res2.Plan != nil && len(res2.Plan.Steps) == 1 && res2.Plan.Steps[0].ID == "b1" {
|
||||
t.Fatalf("nil gate must not route to blueprint: %+v", res2.Plan)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user