Wire plugin slash aliases and the intent declaration surface (G2-2)

Why: plan G2-2 requires plugin commands reachable via slash aliases
through the same bus and audit envelope (EXT-10②), and a natural-
language routing entry so '帮我写项目建议书' can reach the compile
flow. The agent-command lib now carries a validated Slash field
(lib PR #6, pin bumped to 4f583ca); the bus binds plugin-spec aliases
into the same slash table; intents are declared via the extension
registry and routed deterministically (normalized containment, longest
phrase first) to commands through the bus — the system.intent builtin
(/intent) is the chat-entrance dispatch point with a candidate list on
miss.

Evidence: TestPluginSlashAliasBindsAndDispatches — alias binds, case/
whitespace-normalized resolution, slash and button entrances hit the
same command handler with committed receipts; TestIntentDeclareMatch
Route — declare/validate, miss on unrelated text, normalized hit,
bus-dispatch through the chat entrance, longest-phrase priority;
server suite green with the dual-entry pair registered; full go test
./... 31/31 packages green.

Impact: cmdbus plugin-spec slash binding + IntentStore; extension
Registry.AddIntent + IntentSpec (SurfaceVersion 2.7.0-experimental);
pluginRegistry.AddIntent wires plugin-declared intents; server
builtin system.intent; command-pairs fixture extended.

Refs: plan G2-2 (line 366); EXT-10②; lib PR #6 (4f583ca).
This commit is contained in:
2026-10-10 00:15:21 +00:00
parent 1a3fbfb3d8
commit 9786dc1456
8 changed files with 298 additions and 7 deletions
+15
View File
@@ -48,6 +48,21 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
})
}
// AddIntent 意图声明(G2-2):汇入总线意图表;插件身份随声明归因。
func (r pluginRegistry) AddIntent(spec json.RawMessage) error {
if r.bus == nil {
return nil
}
var is extension.IntentSpec
if err := json.Unmarshal(spec, &is); err != nil {
return fmt.Errorf("extension: intent spec: %w", err)
}
return r.bus.Intents().Declare(cmdbus.Intent{
ID: is.ID, Phrases: is.Phrases, Command: is.Command,
Params: is.Params, Description: is.Description, Plugin: r.pluginID,
})
}
func (r compiledRegistry) AddSkill(name, markdown string) error {
if r.skillsRoot == "" || name == "" {
return nil
+15
View File
@@ -100,6 +100,7 @@ type Bus struct {
pendMu sync.Mutex
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
@@ -115,6 +116,7 @@ func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus {
return &Bus{
reg: agentcommand.NewRegistry(), audit: audit, cards: cards, now: now,
handlers: map[string]Handler{}, slash: map[string]string{}, pending: map[string]pending{},
intents: NewIntentStore(),
}
}
@@ -169,10 +171,19 @@ func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
}
b.bindPluginSlash(spec)
}
return nil
}
// bindPluginSlash EXT-10②:插件命令规格自带的 slash 别名绑定(与内置命令同一张
// 别名表——四入口同源:slash 命中即同一命令 id、同一审计信封)。
func (b *Bus) bindPluginSlash(spec agentcommand.CommandSpec) {
if spec.Slash != "" {
b.slash[strings.ToLower(spec.Slash)] = spec.ID
}
}
// RegisterPluginCommand 注册进程内插件命令(EXT-1):登记规格并绑定宿主处理器。
// 仅登记规格的声明式路径仍走 RegisterPluginCommands(无 handler,分发即 rejected)。
func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) error {
@@ -182,9 +193,13 @@ func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) er
if h != nil {
b.handlers[spec.ID] = h
}
b.bindPluginSlash(spec)
return nil
}
// Intents 意图声明表(G2-2:注册面与路由查询共用)。
func (b *Bus) Intents() *IntentStore { return b.intents }
// ResolveSlash `/` 入口解析:返回命令 id 与原始字段(参数由宿主处理器按命令语义解析)。
func (b *Bus) ResolveSlash(cmd string) (string, bool) {
id, ok := b.slash[strings.ToLower(strings.TrimSpace(cmd))]
+96
View File
@@ -0,0 +1,96 @@
// Package cmdbuf 意图路由(G2-2:Intent 声明面与自然语言入口)。
// 插件经 extension.Registry.AddIntent 声明「短语 → 命令」映射;宿主把声明汇入
// 本表。匹配口径:文本归一(去空白/小写)后做包含匹配,最长短语优先——
// 无模型参与的确定性第一档;路由终点仍是命令总线(同审计信封与 HITL 收口)。
package cmdbus
import (
"fmt"
"sort"
"strings"
"sync"
)
// Intent 一条意图声明:短语命中后分发到 CommandID(参数模板随声明携带)。
type Intent struct {
ID string `json:"id"` // 形如 <domain>.<intent>(如 rdm.write-proposal)
Phrases []string `json:"phrases"` // 触发短语(归一后包含匹配)
Command string `json:"command"` // 目标命令 id(须已注册)
Params map[string]any `json:"params,omitempty"` // 参数模板(可被调用方覆盖)
Description string `json:"description,omitempty"`
Plugin string `json:"plugin"` // 声明方(审计归因)
}
// IntentStore 意图声明表(进程内;插件注册期写入,运行期只读)。
type IntentStore struct {
mu sync.RWMutex
byID map[string]Intent
phrases []string // 归一短语(排序后长词优先匹配)
}
// NewIntentStore 建表。
func NewIntentStore() *IntentStore {
return &IntentStore{byID: map[string]Intent{}}
}
// normalize 意图匹配归一:小写、压缩空白。
func normalize(s string) string {
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
// Declare 登记意图(同 id 覆盖;短语与命令必填——fail-closed)。
func (s *IntentStore) Declare(in Intent) error {
if in.ID == "" || in.Command == "" || len(in.Phrases) == 0 {
return fmt.Errorf("cmdbus: intent %q 声明不完整(id/phrases/command 必填)", in.ID)
}
s.mu.Lock()
defer s.mu.Unlock()
s.byID[in.ID] = in
s.rebuild()
return nil
}
func (s *IntentStore) rebuild() {
s.phrases = s.phrases[:0]
for _, in := range s.byID {
for _, p := range in.Phrases {
s.phrases = append(s.phrases, normalize(p))
}
}
// 长短语优先:更具体的意图先命中。
sort.Slice(s.phrases, func(i, j int) bool { return len(s.phrases[i]) > len(s.phrases[j]) })
}
// Match 自然语言匹配:返回命中的意图(ok=false=未命中)。
func (s *IntentStore) Match(text string) (Intent, bool) {
n := normalize(text)
if n == "" {
return Intent{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, ph := range s.phrases {
if ph != "" && strings.Contains(n, ph) {
for _, in := range s.byID {
for _, p := range in.Phrases {
if normalize(p) == ph {
return in, true
}
}
}
}
}
return Intent{}, false
}
// List 全部意图声明(清单面)。
func (s *IntentStore) List() []Intent {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Intent, 0, len(s.byID))
for _, in := range s.byID {
out = append(out, in)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
+97
View File
@@ -0,0 +1,97 @@
package cmdbus
import (
"context"
"encoding/json"
"testing"
agentcommand "agent-command"
)
// EXT-10② 红测:插件命令规格自带 slash 别名 → 四入口同源(slash 命中同一命令
// id,经总线分发同审计信封)。
func TestPluginSlashAliasBindsAndDispatches(t *testing.T) {
bus := New(nopAudit{}, nil, nil)
called := ""
if err := bus.RegisterPluginCommand(spec(t, `{
"id": "rdm.ping", "title": "自检", "slash": "/rdm.ping"
}`), func(ctx context.Context, req Request) (any, error) {
called = req.ID + "|" + req.Entrance
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
id, ok := bus.ResolveSlash("/rdm.ping")
if !ok || id != "rdm.ping" {
t.Fatalf("ResolveSlash = %q ok=%v(插件别名未绑定)", id, ok)
}
// 大小写与首尾空白归一。
if id, ok := bus.ResolveSlash(" /RDM.PING "); !ok || id != "rdm.ping" {
t.Fatalf("归一解析失败: %q %v", id, ok)
}
resp := bus.Dispatch(context.Background(), Request{ID: id, Entrance: "slash", Owner: "alice"})
if resp.Receipt.State != "committed" {
t.Fatalf("slash 分发 = %+v", resp.Receipt)
}
if called != "rdm.ping|slash" {
t.Fatalf("处理器见到 %q(须同命令 id)", called)
}
// 同一命令按钮入口分发——同源同信封。
if resp2 := bus.Dispatch(context.Background(), Request{ID: "rdm.ping", Entrance: "button", Owner: "alice"}); resp2.Receipt.State != "committed" {
t.Fatalf("button 分发 = %+v", resp2.Receipt)
}
}
// G2-2 红测:意图声明→归一包含匹配(最长短语优先)→路由到命令(chat 入口)。
func TestIntentDeclareMatchRoute(t *testing.T) {
bus := New(nopAudit{}, nil, nil)
_ = bus.RegisterPluginCommand(spec(t, `{
"id": "rdm.draft.compose", "title": "起草"
}`), func(ctx context.Context, req Request) (any, error) {
return map[string]any{"sectionId": req.Params["sectionId"]}, nil
})
if err := bus.Intents().Declare(Intent{
ID: "rdm.write-proposal", Phrases: []string{"帮我写项目建议书", "写项目建议书"},
Command: "rdm.draft.compose", Params: map[string]any{"sectionId": "sec-1"},
Plugin: "rdm",
}); err != nil {
t.Fatal(err)
}
// 未命中。
if _, ok := bus.Intents().Match("今天天气如何"); ok {
t.Fatal("无关文本不得命中")
}
// 命中(含大小写/空白归一)。
in, ok := bus.Intents().Match("请 帮我写项目建议书,尽快")
if !ok || in.ID != "rdm.write-proposal" {
t.Fatalf("匹配 = %+v ok=%v", in, ok)
}
// 路由:匹配到的命令经总线 chat 入口分发成功。
resp := bus.Dispatch(context.Background(), Request{ID: in.Command, Params: in.Params, Entrance: "chat", Owner: "alice"})
if resp.Receipt.State != "committed" {
t.Fatalf("路由分发 = %+v", resp.Receipt)
}
// 声明校验:缺短语拒绝。
if err := bus.Intents().Declare(Intent{ID: "x", Command: "y"}); err == nil {
t.Fatal("不完整声明须拒绝")
}
// 最长短语优先:更具体的意图先命中。
if err := bus.Intents().Declare(Intent{
ID: "rdm.write-competitive", Phrases: []string{"写竞品分析"},
Command: "rdm.draft.compose", Plugin: "rdm",
}); err != nil {
t.Fatal(err)
}
if in, _ := bus.Intents().Match("帮我写项目建议书"); in.ID != "rdm.write-proposal" {
t.Fatalf("长短语意图应先命中: %+v", in)
}
}
func spec(t *testing.T, raw string) agentcommand.CommandSpec {
t.Helper()
var s agentcommand.CommandSpec
if err := json.Unmarshal([]byte(raw), &s); err != nil {
t.Fatal(err)
}
return s
}
+28
View File
@@ -121,6 +121,34 @@ func (s *Server) WireBuiltinCommands() {
"/help", func(ctx context.Context, req cmdbus.Request) (any, error) {
return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助", len(b.List()))}, nil
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由",
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
"/intent", func(ctx context.Context, req cmdbus.Request) (any, error) {
text, _ := req.Params["text"].(string)
in, ok := b.Intents().Match(text)
if !ok {
list := b.Intents().List()
cands := make([]map[string]any, 0, len(list))
for _, x := range list {
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
}
return map[string]any{"matched": false, "candidates": cands}, nil
}
params := map[string]any{}
for k, v := range in.Params {
params[k] = v
}
for k, v := range req.Params {
if k != "text" {
params[k] = v // 调用方可覆盖模板参数
}
}
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
"receipt": resp.Receipt, "payload": resp.Payload}, nil
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话",
ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)},
"/new", func(ctx context.Context, req cmdbus.Request) (any, error) {
+30 -5
View File
@@ -1,7 +1,32 @@
[
{"id": "builtin.help", "button": true, "chat": true},
{"id": "conversation.create", "button": true, "chat": true},
{"id": "runtime.mock", "button": true, "chat": true},
{"id": "models.set", "button": true, "chat": true},
{"id": "conversation.profile", "button": true, "chat": true}
{
"id": "builtin.help",
"button": true,
"chat": true
},
{
"id": "conversation.create",
"button": true,
"chat": true
},
{
"id": "conversation.profile",
"button": true,
"chat": true
},
{
"id": "models.set",
"button": true,
"chat": true
},
{
"id": "runtime.mock",
"button": true,
"chat": true
},
{
"id": "system.intent",
"button": true,
"chat": true
}
]
+16 -1
View File
@@ -20,7 +20,8 @@ import (
// 重建受限宿主句柄,供下游恢复路径)。
// 2.5.0-experimental:Options.DisableExecTools(执行原语启动级硬关闭)。
// 2.6.0-experimental:Options.ExecBackend(EXT-16 执行后端 direct|sandbox)。
const SurfaceVersion = "2.6.0-experimental"
// 2.7.0-experimental:Registry.AddIntent 与 IntentSpec(G2-2 意图声明面)。
const SurfaceVersion = "2.7.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
@@ -41,6 +42,20 @@ type Registry interface {
// RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展);
// 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。
RegisterResourceAuthorizer(authz ResourceAuthorizer) error
// AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令——
// 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。
AddIntent(spec json.RawMessage) error
}
// IntentSpec 意图声明规格(G2-2):id 形如 <domain>.<intent>;phrases 是触发
// 短语(宿主归一后包含匹配,最长短语优先);command 须为已注册命令 id;
// params 为参数模板(调用方可覆盖)。宿主侧路由入口:system.intent 命令。
type IntentSpec struct {
ID string `json:"id"`
Phrases []string `json:"phrases"`
Command string `json:"command"`
Params map[string]any `json:"params,omitempty"`
Description string `json:"description,omitempty"`
}
// InProc 是随下游二进制编译进来的扩展。