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:
weichen
2026-09-29 16:05:33 +00:00
parent be5959d89a
commit f3d000f6a6
17 changed files with 737 additions and 31 deletions
+3
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
Vendored Submodule
+1
Submodule deps/agent-intent added at 55780ae2cc
@@ -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
View File
@@ -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
+7
View File
@@ -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 为可选包,不回退亦可编译)。
+20 -15
View File
@@ -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
View File
@@ -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}
+31
View File
@@ -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)
}
+140
View File
@@ -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 }
+114
View File
@@ -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:交给原路径
}
}
+173
View File
@@ -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)
}
}
+132
View File
@@ -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)
}
}