Add the optional intentgate decorator over agent-intent with pass-through and fail-closed tests
Details (original message): Add the optional intentgate decorator over agent-intent with pass-through and fail-closed tests.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// Package intentgate 提供可选的意图闸门装饰器:在把 Prompt 交给被装饰的
|
||||
// 执行后端之前,用 agent-intent 做一次确定性识别;分支为守卫/命中/预期命中时
|
||||
// 交由宿主 Handler 解释,Ambiguous/Fallback 或 Handler 放弃时原样透传。
|
||||
//
|
||||
// 本包是 Agent-Native 仓内唯一 import agent-intent 的地方。不使用本包时,
|
||||
// 本仓与 agent-intent 无任何编译关系;未 Wrap 或快照零意图时,被装饰后端的
|
||||
// 行为与原来完全一致。
|
||||
package intentgate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// Handler 解释"识别到意图"的执行请求。返回 handled=false 表示宿主放弃处理,
|
||||
// 装饰器回退为透传原后端。合成结果的语义(拒绝、引导、定向执行)由宿主定义,
|
||||
// 本包不发明结果语义。
|
||||
type Handler func(ctx context.Context, req exec.Request, res intent.Result) (result *exec.Result, err error, handled bool)
|
||||
|
||||
type gated struct {
|
||||
next exec.Backend
|
||||
rec intent.Recognizer
|
||||
snap *intent.Snapshot
|
||||
handler Handler
|
||||
}
|
||||
|
||||
// Wrap 返回带意图闸门的后端。next/rec/snap/handler 任一为 nil 时直接返回 next
|
||||
// (等价于未启用,行为不变)。
|
||||
func Wrap(next exec.Backend, rec intent.Recognizer, snap *intent.Snapshot, h Handler) exec.Backend {
|
||||
if next == nil || rec == nil || snap == nil || h == nil {
|
||||
return next
|
||||
}
|
||||
return &gated{next: next, rec: rec, snap: snap, handler: h}
|
||||
}
|
||||
|
||||
func (g *gated) Name() string { return g.next.Name() }
|
||||
|
||||
func (g *gated) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
res, err := g.rec.Recognize(ctx, req.Prompt, g.snap)
|
||||
if err != nil {
|
||||
// 识别系统错误 fail-closed:不静默放行可能应拦截的内容,也不吞错。
|
||||
return nil, err
|
||||
}
|
||||
switch res.Branch {
|
||||
case intent.BranchGuardHit, intent.BranchMatched, intent.BranchExpectationHit:
|
||||
default:
|
||||
return g.next.Execute(ctx, req) // 无裁决:透传原后端
|
||||
}
|
||||
out, herr, handled := g.handler(ctx, req, res)
|
||||
if !handled {
|
||||
return g.next.Execute(ctx, req)
|
||||
}
|
||||
return out, herr
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package intentgate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
type stubBackend struct {
|
||||
name string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubBackend) Name() string { return s.name }
|
||||
|
||||
func (s *stubBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
s.calls++
|
||||
return &exec.Result{Success: true, Logs: "stub:" + req.Prompt}, nil
|
||||
}
|
||||
|
||||
func emptySnapshot(t *testing.T) *intent.Snapshot {
|
||||
t.Helper()
|
||||
reg, err := intent.NewRegistryBuilder(intent.Options{}).Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
|
||||
// 带一个守卫 spec 与一个可路由 spec 的快照。
|
||||
func loadedSnapshot(t *testing.T) *intent.Snapshot {
|
||||
t.Helper()
|
||||
b := intent.NewRegistryBuilder(intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
})
|
||||
if err := b.Register(intent.IntentSpec{
|
||||
ID: "guard.config_change", Domain: "guard", Version: 1,
|
||||
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关"}},
|
||||
Risk: intent.RiskHigh,
|
||||
Handler: "refuse.and_guide",
|
||||
Metadata: map[string]string{
|
||||
"guidance": "系统配置请在管理界面修改",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
|
||||
func newRec() intent.Recognizer { return intent.NewRecognizer(intent.Options{}, nil, nil) }
|
||||
|
||||
func TestZeroSpecSnapshotPassthroughIdentical(t *testing.T) {
|
||||
// T0 行为解耦:零意图快照 + Wrap 后,与裸后端行为逐字节一致。
|
||||
bare := &stubBackend{name: "native"}
|
||||
gatedBe := Wrap(&stubBackend{name: "native"}, newRec(), emptySnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
t.Fatal("handler must not fire on empty snapshot")
|
||||
return nil, nil, false
|
||||
})
|
||||
req := exec.Request{Prompt: "帮我改一下系统网关地址"}
|
||||
want, werr := bare.Execute(context.Background(), req)
|
||||
got, gerr := gatedBe.Execute(context.Background(), req)
|
||||
if werr != nil || gerr != nil {
|
||||
t.Fatalf("execute errors: %v %v", werr, gerr)
|
||||
}
|
||||
if !reflect.DeepEqual(want, got) {
|
||||
t.Fatalf("results differ: %+v vs %+v", want, got)
|
||||
}
|
||||
if gatedBe.(*gated).next.(*stubBackend).calls != 1 {
|
||||
t.Fatal("next backend not invoked exactly once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardHitInterceptsWithoutModel(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
be := Wrap(next, newRec(), loadedSnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
if res.Branch != intent.BranchGuardHit || res.Top == nil || res.Top.ID != "guard.config_change" {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
// 宿主解释守卫意图:拒绝 + 引导(合成结果语义由宿主定义)。
|
||||
return &exec.Result{Success: false, ErrorReason: "guard refused: " + res.Top.Metadata["guidance"]}, nil, true
|
||||
})
|
||||
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Success || out.ErrorReason == "" {
|
||||
t.Fatalf("expected guard refusal, got %+v", out)
|
||||
}
|
||||
if next.calls != 0 {
|
||||
t.Fatalf("model backend must not be called on guard hit, got %d calls", next.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackPassesThroughToBackend(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
be := Wrap(next, newRec(), loadedSnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
t.Fatal("handler must not fire on fallback")
|
||||
return nil, nil, false
|
||||
})
|
||||
out, err := be.Execute(context.Background(), exec.Request{Prompt: "写一份周报"})
|
||||
if err != nil || out == nil || !out.Success {
|
||||
t.Fatalf("fallback must reach backend: %+v %v", out, err)
|
||||
}
|
||||
if next.calls != 1 {
|
||||
t.Fatalf("backend calls = %d", next.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerDeclineFallsBackToBackend(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
be := Wrap(next, newRec(), loadedSnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
return nil, nil, false // 宿主放弃处理
|
||||
})
|
||||
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
|
||||
if err != nil || out == nil || !out.Success {
|
||||
t.Fatalf("declined handling must pass through: %+v %v", out, err)
|
||||
}
|
||||
if next.calls != 1 {
|
||||
t.Fatalf("backend calls = %d", next.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilComponentsReturnNext(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
if got := Wrap(next, nil, nil, nil); got != exec.Backend(next) {
|
||||
t.Fatal("nil components must return next unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeErrorFailsClosed(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
b := intent.NewRegistryBuilder(intent.Options{MaxInputLen: 8})
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
be := Wrap(next, newRec(), reg.Snapshot(),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
t.Fatal("handler must not fire on recognize error")
|
||||
return nil, nil, false
|
||||
})
|
||||
_, err = be.Execute(context.Background(), exec.Request{Prompt: "这一段输入明显超过八个字节"})
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error")
|
||||
}
|
||||
var ie *intent.Error
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *intent.Error, got %T", err)
|
||||
}
|
||||
if next.calls != 0 {
|
||||
t.Fatal("backend must not run on recognize error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user