diff --git a/.gitmodules b/.gitmodules index 032d44a..1581d80 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,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 diff --git a/AGENTS.md b/AGENTS.md index e95c44d..562c326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ - `contract` 不依赖内核、产品类型、数据库或本仓以外的运行时;错误码字符串与 Error-Code-System 的 `agent.*` 规范码对齐,旧字符串只作为别名由错误码库解析。 - 未知结果保持 Unknown,不降级为成功或失败;副作用为 possible/unknown 时禁止无条件重放。 - 缺能力即拒绝,不静默降级到 Stub。Workspace 路径授权不是 OS 沙箱。 +- 意图闸门为可选依赖:唯 `adapters/intentgate` 与其测试可 import agent-intent;contract/exec/runtime 等核心不感知意图。未 Wrap 或意图快照为空时,后端行为与原实现一致;识别系统错误 fail-closed。 - 依赖仓版本以本仓 gitlink 为准,不执行 `submodule update --remote`。 任务权威:Gitea 仓库 `weichen/Agent-Native` 的 Issue 与 Pull Request。 diff --git a/PROJECT.md b/PROJECT.md index 061827e..602d651 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -23,6 +23,7 @@ | development-guidelines | `deps/development-guidelines` | development-only,规范文本 | | error-code-system | `deps/error-code-system` | build-required,规范码目录 | | logging-system | `deps/logging-system` | build-required,可选日志器 | +| agent-intent | `deps/agent-intent` | build-required,可选意图闸门装饰器(`adapters/intentgate`) | 克隆: diff --git a/adapters/intentgate/intentgate.go b/adapters/intentgate/intentgate.go new file mode 100644 index 0000000..7bb54e0 --- /dev/null +++ b/adapters/intentgate/intentgate.go @@ -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 +} diff --git a/adapters/intentgate/intentgate_test.go b/adapters/intentgate/intentgate_test.go new file mode 100644 index 0000000..7bdf43a --- /dev/null +++ b/adapters/intentgate/intentgate_test.go @@ -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") + } +} diff --git a/deps/agent-intent b/deps/agent-intent new file mode 160000 index 0000000..55780ae --- /dev/null +++ b/deps/agent-intent @@ -0,0 +1 @@ +Subproject commit 55780ae2cce4ae98d8b0557b8234af8833a7c522 diff --git a/deps/error-code-system b/deps/error-code-system index 95a03b7..dbeed2f 160000 --- a/deps/error-code-system +++ b/deps/error-code-system @@ -1 +1 @@ -Subproject commit 95a03b75786827b215121c35845d3ca35afc03bb +Subproject commit dbeed2f1b67eb417ce089e7033a228578fbbeb03 diff --git a/docs/records/2026-09-29-intentgate-adoption.md b/docs/records/2026-09-29-intentgate-adoption.md new file mode 100644 index 0000000..3eaeddc --- /dev/null +++ b/docs/records/2026-09-29-intentgate-adoption.md @@ -0,0 +1,23 @@ +# Agent-Native 接入 agent-intent 意图闸门(可选装饰器) + +对应设计:Agent-Intent 仓 `docs/records/2026-09-29-decoupling-and-llm-layering.md`(第三节 b,决策 D7 选仓内装饰器)。 +形态:`adapters/intentgate.Wrap(next, recognizer, snapshot, handler) exec.Backend`——Execute 前对 `req.Prompt` 做一次确定性识别,守卫/命中/预期命中分支交宿主 Handler 解释(合成结果语义由宿主定义),Ambiguous/Fallback 或 Handler 放弃时原样透传;识别系统错误 fail-closed。 + +## 依赖与解耦边界 + +- 本包是本仓唯一 import `agent-intent` 的位置;contract/exec/runtime/adapters/native 零感知(`GOWORK=off go list -deps ./contract ./exec ./runtime ./adapters/native` 不含 agent-intent)。 +- T0 行为解耦:未 Wrap(或任一组件为 nil,直接返回原后端)与 Wrap 了零意图快照,行为与原后端一致(`TestZeroSpecSnapshotPassthroughIdentical` 逐字节对比)。 +- 回退程序:删 `adapters/intentgate/` + go.mod 两行 + submodule + `.gitmodules` 条目 + 一致性脚本一行。 + +## 验证证据(2026-09-29,go test ./... 全绿,intentgate 含 -race) + +| 用例 | 外挂意图 | 输入(Prompt) | 识别结果 | 期望 | 结果 | +|---|---|---|---|---|---| +| TestZeroSpecSnapshotPassthroughIdentical | 无(零 spec 快照) | `帮我改一下系统网关地址` | Fallback(快照为空) | 与裸 stub 后端结果逐字节一致,Handler 不触发 | 通过 | +| TestGuardHitInterceptsWithoutModel | guard.config_change(includes: 系统配置/网关;risk=high) | `帮我改一下系统网关地址` | GuardHit,top=guard.config_change | Handler 收到 GuardHit;返回拒绝合成结果;**模型后端零调用** | 通过 | +| TestFallbackPassesThroughToBackend | 同上 | `写一份周报` | Fallback | 透传原后端,Handler 不触发,后端恰好调用 1 次 | 通过 | +| TestHandlerDeclineFallsBackToBackend | 同上 | `帮我改一下系统网关地址` | GuardHit(Handler 放弃) | 回退透传原后端 | 通过 | +| TestNilComponentsReturnNext | n/a | n/a | n/a | 任一组件为 nil → Wrap 返回原后端 | 通过 | +| TestRecognizeErrorFailsClosed | MaxInputLen=8 快照 | 超长输入 | 识别错误 sys.invalid_argument | fail-closed 返回错误,后端零调用 | 通过 | + +命令:`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...`;`GOWORK=off go test -count=1 -race ./adapters/intentgate/`;`scripts/submodule-consistency.sh`。 diff --git a/go.mod b/go.mod index f46d604..a207f6f 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module agent-native go 1.27 require ( + agent-intent v0.0.0 error-code-system v0.0.0 logging-system v0.0.0 ) +replace agent-intent => ./deps/agent-intent + replace error-code-system => ./deps/error-code-system replace logging-system => ./deps/logging-system diff --git a/policy/guidelines-adoption.md b/policy/guidelines-adoption.md index 31ea0f8..4c76d8e 100644 --- a/policy/guidelines-adoption.md +++ b/policy/guidelines-adoption.md @@ -42,3 +42,10 @@ - `.gitmodules` 使用批准的上游 SSH URL,不含本机路径或凭证。 - 执行方式保持 manual:挂载 Markdown 不自动改变 CI。 - 回退:恢复本提交之前的 gitlink,并删除根入口与 `policy/guidelines-adoption.md` 中对应条款;无已生效的 CI 或权限变更需要另恢复。 + +### 2026-09-29 追加:agent-intent 可选意图闸门 + +- 新增 `deps/agent-intent`(固定 `55780ae2cce4ae98d8b0557b8234af8833a7c522`),`deps/error-code-system` 前进到 `dbeed2f1b67eb417ce089e7033a228578fbbeb03`(追加 `squad.intent_refused`,兼容性变更无)。 +- 依赖边界新增:唯 `adapters/intentgate` 可 import agent-intent;不使用该包时核心与 agent-intent 无编译关系。 +- 接入与验证记录见 [docs/records/2026-09-29-intentgate-adoption.md](../docs/records/2026-09-29-intentgate-adoption.md)。 +- 回退:删除 `adapters/intentgate/`、go.mod 的 agent-intent require/replace、`.gitmodules` 对应条目与 submodule,`scripts/submodule-consistency.sh` 移除该路径。 diff --git a/scripts/submodule-consistency.sh b/scripts/submodule-consistency.sh index 76d427b..4b5bd61 100755 --- a/scripts/submodule-consistency.sh +++ b/scripts/submodule-consistency.sh @@ -4,7 +4,7 @@ set -eu cd "$(dirname "$0")/.." fail=0 -for path in deps/development-guidelines deps/error-code-system deps/logging-system; do +for path in deps/development-guidelines deps/error-code-system deps/logging-system deps/agent-intent; do if [ ! -e "$path/.git" ]; then echo "missing submodule checkout: $path" >&2 fail=1