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.
This commit is contained in:
weichen
2026-09-29 18:11:47 +00:00
parent 964e4fb545
commit 30ad92730d
12 changed files with 283 additions and 11 deletions
+9
View File
@@ -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
Vendored Submodule
+1
Submodule deps/agent-intent added at 7d9d505d65
Vendored Submodule
+1
Submodule deps/error-code-system added at dbeed2f1b6
Vendored Submodule
+1
Submodule deps/logging-system added at 2c5716fdcf
+15 -7
View File
@@ -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
+7
View File
@@ -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:单个失败不阻断启动
+95
View File
@@ -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
}
+67
View File
@@ -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")
}
}
+25
View File
@@ -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["<intents>"] = 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 {
+1 -1
View File
@@ -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"}]`)
}
+23 -3
View File
@@ -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}
+38
View File
@@ -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"])
}
}