Merge origin/main into feature/agent-quality-phase05
ci / vet-and-test (pull_request) Successful in 40s
ci / vet-and-test (pull_request) Successful in 40s
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-command updated: 5c6dd0c1ba...4f583ca3ee
@@ -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
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 是随下游二进制编译进来的扩展。
|
||||
|
||||
@@ -19,6 +19,7 @@ Host.TaskCreate
|
||||
Host.TaskUpdate
|
||||
HostClient
|
||||
InProc
|
||||
IntentSpec
|
||||
Main
|
||||
NewHostClient
|
||||
NewScopedHost
|
||||
@@ -27,6 +28,7 @@ RegisterCompiled
|
||||
Registry
|
||||
Registry.AddCommand
|
||||
Registry.AddCommandHandler
|
||||
Registry.AddIntent
|
||||
Registry.AddSkill
|
||||
Registry.Note
|
||||
Registry.RegisterResourceAuthorizer
|
||||
|
||||
Reference in New Issue
Block a user