From 30ad92730d754714b7b4689015a5ca1cea8f32ba Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 29 Sep 2026 18:11:47 +0000 Subject: [PATCH] Integrate the agent-intent gate Details (original message): Integrate the agent-intent gate: plugin intent overlays rebuild the registry (fail-closed, keeps previous snapshot), HITL free-input pivots on matched foreign intents; unit and e2e tests; pin logging-system and error-code-system submodules. --- .gitmodules | 9 +++ deps/agent-intent | 1 + deps/error-code-system | 1 + deps/logging-system | 1 + go.mod | 22 +++++--- internal/app/app.go | 7 +++ internal/intentgate/gate.go | 95 ++++++++++++++++++++++++++++++++ internal/intentgate/gate_test.go | 67 ++++++++++++++++++++++ internal/plugins/host.go | 25 +++++++++ internal/plugins/host_test.go | 2 +- internal/runmgr/runmgr.go | 26 ++++++++- internal/server/e2e_test.go | 38 +++++++++++++ 12 files changed, 283 insertions(+), 11 deletions(-) create mode 160000 deps/agent-intent create mode 160000 deps/error-code-system create mode 160000 deps/logging-system create mode 100644 internal/intentgate/gate.go create mode 100644 internal/intentgate/gate_test.go diff --git a/.gitmodules b/.gitmodules index 8c7003f..360925a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,12 @@ [submodule "deps/agent-native"] path = deps/agent-native url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git +[submodule "deps/agent-intent"] + path = deps/agent-intent + url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git +[submodule "deps/logging-system"] + path = deps/logging-system + url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git +[submodule "deps/error-code-system"] + path = deps/error-code-system + url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git diff --git a/deps/agent-intent b/deps/agent-intent new file mode 160000 index 0000000..7d9d505 --- /dev/null +++ b/deps/agent-intent @@ -0,0 +1 @@ +Subproject commit 7d9d505d6523ecbf3b103c64d766f463769f4015 diff --git a/deps/error-code-system b/deps/error-code-system new file mode 160000 index 0000000..dbeed2f --- /dev/null +++ b/deps/error-code-system @@ -0,0 +1 @@ +Subproject commit dbeed2f1b67eb417ce089e7033a228578fbbeb03 diff --git a/deps/logging-system b/deps/logging-system new file mode 160000 index 0000000..2c5716f --- /dev/null +++ b/deps/logging-system @@ -0,0 +1 @@ +Subproject commit 2c5716fdcffbc4696fc0371afc70f39c44174201 diff --git a/go.mod b/go.mod index e01af44..968e2e2 100644 --- a/go.mod +++ b/go.mod @@ -2,25 +2,33 @@ module agent-system-backend go 1.27 -replace agent-system-core => ./deps/agent-system-core - -replace agent-native => ./deps/agent-native - require ( - agent-native v0.0.0-00010101000000-000000000000 - agent-system-core v0.0.0-00010101000000-000000000000 + agent-intent v0.0.0 + agent-native v0.0.0 + agent-system-core v0.0.0 + github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 modernc.org/sqlite v1.60.1 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.48.0 // indirect + logging-system v0.0.0 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect ) + +replace agent-intent => ./deps/agent-intent + +replace agent-native => ./deps/agent-native + +replace agent-system-core => ./deps/agent-system-core + +replace error-code-system => ./deps/error-code-system + +replace logging-system => ./deps/logging-system diff --git a/internal/app/app.go b/internal/app/app.go index 542ac76..e845f25 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" + "encoding/json" "fmt" "log" "net/http" @@ -14,6 +15,7 @@ import ( core "agent-system-core" "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" @@ -61,7 +63,12 @@ func Run(opt Options) error { mockRunner := &runtime.MockRunner{} mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{}) + // 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。 + gate := intentgate.New() + mgr.SetGate(gate) + pluginHost := plugins.New(st, reg, setSvc, opt.PluginsDir) + pluginHost.OnIntents = func(overlay []json.RawMessage) error { return gate.Rebuild(context.Background(), overlay) } for id, err := range pluginHost.LoadDir() { if err != nil { log.Printf("[plugins] %s: %v", id, err) // fail-closed:单个失败不阻断启动 diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go new file mode 100644 index 0000000..518b6ab --- /dev/null +++ b/internal/intentgate/gate.go @@ -0,0 +1,95 @@ +// Package intentgate 接入 agent-intent(生态仓):插件意图 overlay → 注册表 → +// HITL 自由输入路由的确定性识别(设计 4.5 三分支之「命中其他意图→转轨」)。 +// 未配置/未装载意图 = 行为回退(T0:直接按澄清回填),识别失败不阻断。 +package intentgate + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + agentintent "agent-intent/intent" +) + +type Gate struct { + mu sync.RWMutex + reg *agentintent.Registry + rec *agentintent.FunnelRecognizer + specs int +} + +func New() *Gate { + return &Gate{rec: agentintent.NewRecognizer(agentintent.Options{}, nil, nil)} +} + +// Rebuild 用插件意图 overlay 重建注册表;失败保留上一快照(fail-closed)并返回错误。 +func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error { + if len(overlay) == 0 { + g.mu.Lock() + g.reg, g.specs = nil, 0 + g.mu.Unlock() + return nil + } + specs := make([]agentintent.IntentSpec, 0, len(overlay)) + for i, raw := range overlay { + var s agentintent.IntentSpec + if err := json.Unmarshal(raw, &s); err != nil { + return fmt.Errorf("intentgate: spec[%d]: %w", i, err) + } + specs = append(specs, s) + } + b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3}) + if err := b.Register(specs...); err != nil { + return fmt.Errorf("intentgate: register: %w", err) + } + reg, err := b.Build() + if err != nil { + return fmt.Errorf("intentgate: build: %w", err) + } + g.mu.Lock() + g.reg, g.specs = reg, len(specs) + g.mu.Unlock() + return nil +} + +// Match 识别自由输入;返回 (branch, topID)。 +// branch:matched | ambiguous | fallback | ""(未配置/系统级错误 → 回退通道)。 +func (g *Gate) Match(ctx context.Context, input string) (string, string) { + g.mu.RLock() + reg, rec := g.reg, g.rec + g.mu.RUnlock() + if reg == nil { + return "", "" + } + res, err := rec.Recognize(ctx, input, reg.Snapshot()) + if err != nil { + return "", "" + } + topID := "" + if res.Top != nil { + topID = res.Top.ID + } + switch res.Branch { + case agentintent.BranchMatched: + return "matched", topID + case agentintent.BranchAmbiguous: + return "ambiguous", topID + default: + return "fallback", topID + } +} + +// Enabled 报告是否装载了意图(诊断/设置展示用)。 +func (g *Gate) Enabled() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.reg != nil && g.specs > 0 +} + +// SpecCount 返回已装载意图数。 +func (g *Gate) SpecCount() int { + g.mu.RLock() + defer g.mu.RUnlock() + return g.specs +} diff --git a/internal/intentgate/gate_test.go b/internal/intentgate/gate_test.go new file mode 100644 index 0000000..df85178 --- /dev/null +++ b/internal/intentgate/gate_test.go @@ -0,0 +1,67 @@ +package intentgate + +import ( + "context" + "encoding/json" + "testing" +) + +func raw(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +// spec 采用 agent-intent overlay 形态(matcher 嵌套)。 +func demoSpec(t *testing.T) json.RawMessage { + return raw(t, map[string]any{ + "id": "demo.show", "domain": "demo", "version": 1, + "description": "示例意图", + "matcher": map[string]any{"includes_any": []string{"示例", "演示"}}, + }) +} + +func TestGateMatchAndFallback(t *testing.T) { + g := New() + if _, id := g.Match(context.Background(), "任意"); id != "" || g.Enabled() { + t.Error("empty gate should passthrough") + } + if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil { + t.Fatalf("rebuild: %v", err) + } + if !g.Enabled() || g.SpecCount() != 1 { + t.Fatalf("enabled=%v specs=%d", g.Enabled(), g.SpecCount()) + } + branch, top := g.Match(context.Background(), "来个示例看看") + if branch != "matched" || top != "demo.show" { + t.Errorf("want matched/demo_intent, got %s/%s", branch, top) + } + branch, _ = g.Match(context.Background(), "完全无关的一句话") + if branch != "fallback" { + t.Errorf("want fallback, got %s", branch) + } +} + +// fail-closed:坏 spec 保留上一快照。 +func TestGateFailClosed(t *testing.T) { + g := New() + if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil { + t.Fatal(err) + } + if err := g.Rebuild(context.Background(), []json.RawMessage{json.RawMessage(`{"id":`)}); err == nil { + t.Error("broken spec should error") + } + if !g.Enabled() || g.SpecCount() != 1 { + t.Error("previous snapshot must be kept on failed rebuild") + } + // 空重建 = 显式清空(卸载全部插件意图)。 + if err := g.Rebuild(context.Background(), nil); err != nil { + t.Fatal(err) + } + if g.Enabled() { + t.Error("empty rebuild should disable gate") + } +} diff --git a/internal/plugins/host.go b/internal/plugins/host.go index ab876a5..cfd41ad 100644 --- a/internal/plugins/host.go +++ b/internal/plugins/host.go @@ -39,6 +39,8 @@ type Host struct { settings *settings.Service dir string loaded map[string]*Loaded // id → loaded + // OnIntents 在装载集合变化后回调全部意图 overlay(宿主接识别闸门;nil = 不接)。 + OnIntents func(overlay []json.RawMessage) error } func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string) *Host { @@ -72,7 +74,14 @@ func (h *Host) LoadDir() map[string]error { h.registry.UnregisterBySource(core.SourcePlugin, id) } } + overlay := h.collectIntentsLocked() + onIntents := h.OnIntents h.mu.Unlock() + if onIntents != nil { + if err := onIntents(overlay); err != nil { + results[""] = err + } + } return results } @@ -184,11 +193,27 @@ func (h *Host) loadOne(dir string) error { h.mu.Lock() h.loaded[m.ID] = ld + overlay := h.collectIntentsLocked() + onIntents := h.OnIntents h.mu.Unlock() _ = h.st.UpsertPlugin(m.ID, dir, m) + if onIntents != nil { + if err := onIntents(overlay); err != nil { + return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err) + } + } return nil } +// collectIntentsLocked 汇总全部已装载插件的意图 overlay(调用方持锁)。 +func (h *Host) collectIntentsLocked() []json.RawMessage { + var out []json.RawMessage + for _, ld := range h.loaded { + out = append(out, ld.Intents...) + } + return out +} + // bindToolExecutor:声明式 HTTP 工具绑定执行器;纯描述型(无 http)工具暂不支持(代码插件为远期 sidecar 档)。 func (h *Host) bindToolExecutor(desc core.ToolDesc) (tools.Executor, error) { if desc.HTTP == nil { diff --git a/internal/plugins/host_test.go b/internal/plugins/host_test.go index 54400d5..479eeb8 100644 --- a/internal/plugins/host_test.go +++ b/internal/plugins/host_test.go @@ -47,7 +47,7 @@ func writeExamplePlugin(t *testing.T, dir, id string) { "http":{"method":"POST","url":"https://api.example.com/echo","bodyTemplate":"{\"q\":\"{{param.text}}\"}","resultPath":"echo"}}`) writeFile("panels/demo.json", `{"id":"demo","title":"示例面板","body":[{"type":"markdown","markdown":"# 示例\n插件面板。"}]}`) writeFile("cards/note.json", `{"kind":"note","data":[{"type":"markdown","markdown":"插件卡片模板"}]}`) - writeFile("intents/demo.json", `[{"id":"demo_intent","domain":"demo","includes_any":["示例"]}]`) + writeFile("intents/demo.json", `[{"id":"demo.show","domain":"demo","includes_any":["示例"]}]`) writeFile("prompts/assistant.md", `你是示例插件附带的助手模板。`) writeFile("settings.json", `[{"key":"greeting","group":"plugin:`+id+`","label":"问候语","type":"string","default":"你好","effect":"hot"}]`) } diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index a1960a7..78f53f6 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -29,6 +29,7 @@ type Manager struct { subRunner runtime.Runner // subagent runner(LLM 轻任务) mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式) mockSub runtime.Runner // 子代理脚本回放 + gate IntentGate // 可选意图闸门(nil = 未启用,行为回退) mu sync.Mutex active map[string]*runHandle @@ -50,6 +51,14 @@ func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service, return &Manager{st: st, bus: b, registry: reg, profiles: p, settings: s, runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner, active: map[string]*runHandle{}} } +// IntentGate 是可选意图闸门端口(intentgate.Gate 实现;nil = 未启用,行为回退)。 +type IntentGate interface { + Match(ctx context.Context, input string) (branch, topID string) +} + +// SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。 +func (m *Manager) SetGate(g IntentGate) { m.gate = g } + // ---- runCtx 实现(runtime.RunCtx)---- type runCtx struct { @@ -395,12 +404,23 @@ func (m *Manager) ResolveHITL(cardID string, resp core.HITLResponse) (pivotText } else if resp.FreeText != "" { kind = "free" answer = resp.FreeText - if strings.HasPrefix(strings.TrimSpace(resp.FreeText), "/") { - // 命令转轨:取消挂起 run,等价于用户直接发送该命令。 + trimmed := strings.TrimSpace(resp.FreeText) + // 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。 + 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" { + pivot, pivotReason = true, "intent:"+topID + } + } + if pivot { _ = m.CancelRun(run.ID) m.st.ResolveHITLCard(cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) - return strings.TrimSpace(resp.FreeText), true, nil + _ = pivotReason + return trimmed, true, nil } } resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind} diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index a38009a..af62b71 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -2,6 +2,7 @@ package server import ( "bufio" + "context" "bytes" "encoding/json" "io" @@ -16,6 +17,7 @@ import ( core "agent-system-core" "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" @@ -453,3 +455,39 @@ func TestStaticServed(t *testing.T) { } _ = io.Discard } + +// TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(agent-intent 闸门)。 +func TestHITLIntentPivot(t *testing.T) { + srv, _ := newTestServer(t) + h := srv.Handler() + + gate := intentgate.New() + if err := gate.Rebuild(context.Background(), []json.RawMessage{json.RawMessage(`{"id":"demo.show","domain":"demo","version":1,"matcher":{"includes_any":["示例"]}}`)}); err != nil { + t.Fatal(err) + } + srv.mgr.SetGate(gate) + + _, convID := setupConversation(t, h) + runID := postMessage(t, h, convID, "#hitl") + var cardID string + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && cardID == "" { + _, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) + if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 { + cardID = cards[0].(map[string]any)["id"].(string) + } + time.Sleep(30 * time.Millisecond) + } + if cardID == "" { + t.Fatal("no card") + } + // 自由输入命中 demo_intent → 转轨(pivoted=true,挂起 run 取消)。 + out := mustCode(t, h, "POST", "/api/hitl/"+cardID+"/resolve", map[string]any{"freeText": "来个示例演示"}) + if out["pivoted"] != true { + t.Fatalf("intent hit should pivot: %v", out) + } + terminal := waitRunTerminal(t, h, runID, 5*time.Second) + if terminal["status"] != "cancelled" { + t.Fatalf("suspended run should cancel on intent pivot, got %v", terminal["status"]) + } +}