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:
@@ -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
|
||||
|
||||
+1
Submodule deps/agent-intent added at 7d9d505d65
+1
Submodule deps/error-code-system added at dbeed2f1b6
+1
Submodule deps/logging-system added at 2c5716fdcf
@@ -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
|
||||
|
||||
@@ -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:单个失败不阻断启动
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}]`)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user