From 9786dc145698f717475404e1efcb1b93592d8800 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 10 Oct 2026 00:15:21 +0000 Subject: [PATCH] Wire plugin slash aliases and the intent declaration surface (G2-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- deps/agent-command | 2 +- internal/app/compiled.go | 15 ++++ internal/cmdbus/cmdbus.go | 15 ++++ internal/cmdbus/intents.go | 96 ++++++++++++++++++++ internal/cmdbus/slash_intent_test.go | 97 +++++++++++++++++++++ internal/server/server.go | 28 ++++++ internal/server/testdata/command-pairs.json | 35 ++++++-- pkg/extension/extension.go | 17 +++- 8 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 internal/cmdbus/intents.go create mode 100644 internal/cmdbus/slash_intent_test.go diff --git a/deps/agent-command b/deps/agent-command index 5c6dd0c..4f583ca 160000 --- a/deps/agent-command +++ b/deps/agent-command @@ -1 +1 @@ -Subproject commit 5c6dd0c1bad8c6842600887758a33b7d226441ec +Subproject commit 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 diff --git a/internal/app/compiled.go b/internal/app/compiled.go index 9e75970..2b39059 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -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 diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 2062d66..8945888 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -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))] diff --git a/internal/cmdbus/intents.go b/internal/cmdbus/intents.go new file mode 100644 index 0000000..dde7e16 --- /dev/null +++ b/internal/cmdbus/intents.go @@ -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"` // 形如 .(如 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 +} diff --git a/internal/cmdbus/slash_intent_test.go b/internal/cmdbus/slash_intent_test.go new file mode 100644 index 0000000..6c01f05 --- /dev/null +++ b/internal/cmdbus/slash_intent_test.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 417c1ba..ec00459 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 切换画像 · /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) { diff --git a/internal/server/testdata/command-pairs.json b/internal/server/testdata/command-pairs.json index bd312c2..82c86a9 100644 --- a/internal/server/testdata/command-pairs.json +++ b/internal/server/testdata/command-pairs.json @@ -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 + } ] diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 5aa77fe..cd208eb 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -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 形如 .;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 是随下游二进制编译进来的扩展。