Whitelist B-class surface actions for help and intent #167

Merged
weichen merged 2 commits from feature/genui-bclass-help-intent into main 2026-10-12 03:23:57 +00:00
6 changed files with 167 additions and 32 deletions
+16 -5
View File
@@ -21,11 +21,21 @@ const PluginID = "generative-ui"
const (
// CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。
CommandPresent = PluginID + ".present"
// CommandSubmit 是 U5 已批准的唯页面动作(C 类:表单数据落插件 KV 台账;
// CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账;
// 不 exposeAsTool——按钮专用,模型不可直接调用)。
CommandSubmit = PluginID + ".submit"
// ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。
ActionHelp = "builtin.help"
ActionIntent = "system.intent"
)
// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。
var AllowedActionIDs = map[string]struct{}{
CommandSubmit: {},
ActionHelp: {},
ActionIntent: {},
}
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
@@ -46,8 +56,9 @@ tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
actions 节点纪律:action 的 id 当前仅允许 "generative-ui.submit"(表单提交,携带整页表单草稿);
引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、
builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、
不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
{"title":"示例页","nodes":[
{"type":"section","id":"n1","title":"结论","children":[
@@ -133,8 +144,8 @@ func ValidateActionIDs(spec []byte) []string {
walk = func(n core.UINode, path string) {
if n.Type == core.UIActions {
for _, a := range n.Actions {
if a.ID != CommandSubmit {
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(当前仅允许 %s)", path, a.ID, CommandSubmit))
if _, ok := AllowedActionIDs[a.ID]; !ok {
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID))
}
}
}
+14 -1
View File
@@ -190,7 +190,7 @@ func TestRegisterSpecShape(t *testing.T) {
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
t.Fatalf("present must expose as tool: %s", byID[CommandPresent])
}
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit"} {
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit", "builtin.help", "system.intent"} {
if !strings.Contains(spec.Description, need) {
t.Fatalf("catalog incomplete: missing %q", need)
}
@@ -248,6 +248,19 @@ func TestPresentRejectsUnknownActionIDs(t *testing.T) {
}
}
func TestValidateActionIDsAllowsBClassHelpAndIntent(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"title": "t", "nodes": []any{map[string]any{
"type": "actions", "id": "a1",
"actions": []any{
map[string]any{"id": ActionHelp, "label": "帮助"},
map[string]any{"id": ActionIntent, "label": "意图"},
map[string]any{"id": CommandSubmit, "label": "提交"},
}}}})
if errs := ValidateActionIDs(raw); len(errs) != 0 {
t.Fatalf("B-class help/intent + submit should pass, got %v", errs)
}
}
func TestSubmitFormStoresLedger(t *testing.T) {
// C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。
var kv map[string]string = map[string]string{}
+76
View File
@@ -0,0 +1,76 @@
package server
import (
"context"
"testing"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
)
// B 类硬化红测:button 入口命中写意图时不嵌套分发(无目标命令 receipt)。
func TestSystemIntentButtonNoNestedDispatch(t *testing.T) {
var writes int
b := cmdbus.New(nopAudit{}, nil, nil)
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话"},
"/new", func(ctx context.Context, req cmdbus.Request) (any, error) {
writes++
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
if err := b.Intents().Declare(cmdbus.Intent{
ID: "demo.new", Phrases: []string{"新建会话"}, Command: "conversation.create", Plugin: "test",
}); err != nil {
t.Fatal(err)
}
res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
ID: "system.intent", Entrance: "button", Owner: "alice",
Params: map[string]any{"text": "请新建会话"},
})
if err != nil {
t.Fatal(err)
}
m, ok := res.(map[string]any)
if !ok || m["matched"] != true || m["dispatched"] != false {
t.Fatalf("button match must not dispatch: %+v", res)
}
if writes != 0 {
t.Fatalf("nested write command must not run, writes=%d", writes)
}
// chat 入口仍可嵌套分发(既有行为)
res2, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
ID: "system.intent", Entrance: "chat", Owner: "alice",
Params: map[string]any{"text": "请新建会话"},
})
if err != nil {
t.Fatal(err)
}
m2, _ := res2.(map[string]any)
if m2["dispatched"] != true || writes != 1 {
t.Fatalf("chat entrance should nest-dispatch: %+v writes=%d", res2, writes)
}
}
func TestSystemIntentUnmatchedCandidates(t *testing.T) {
b := cmdbus.New(nopAudit{}, nil, nil)
_ = b.Intents().Declare(cmdbus.Intent{
ID: "demo.x", Phrases: []string{"特定短语"}, Command: "builtin.help", Plugin: "test",
})
res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
Entrance: "button", Params: map[string]any{"text": "无关内容"},
})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]any)
if m["matched"] != false {
t.Fatalf("want unmatched, got %+v", res)
}
cands, _ := m["candidates"].([]map[string]any)
if len(cands) != 1 {
t.Fatalf("want 1 candidate, got %+v", m["candidates"])
}
}
+34 -24
View File
@@ -167,32 +167,10 @@ func (s *Server) WireBuiltinCommands() {
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 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单",
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单;button 入口只读匹配、不嵌套分发",
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
return handleSystemIntent(ctx, b, req)
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话",
ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)},
@@ -236,6 +214,38 @@ func (s *Server) WireBuiltinCommands() {
}))
}
// handleSystemIntent 实现 system.intent:命中后默认嵌套分发目标命令。
// B 类硬化:Entrance=button(页面动作)只返回匹配结果,禁止嵌套业务写路径。
func handleSystemIntent(ctx context.Context, b *cmdbus.Bus, 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
}
if agentcommand.CanonicalEntrance(req.Entrance) == "button" {
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
"dispatched": false, "note": "button entrance: match only, no nested dispatch"}, 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,
"dispatched": true, "receipt": resp.Receipt, "payload": resp.Payload}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
+23
View File
@@ -76,6 +76,29 @@ func newActionFixture(t *testing.T) *actionFixture {
return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision}
}
func TestAllowedActionsIncludesBClassHelpAndIntent(t *testing.T) {
for _, id := range []string{"builtin.help", "system.intent", "generative-ui.submit"} {
if cmd, ok := AllowedActions[id]; !ok || cmd != id {
t.Fatalf("AllowedActions missing 1:1 binding for %q: %q ok=%v", id, cmd, ok)
}
}
}
func TestSubmitActionBClassHelpDispatches(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
var got string
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
got = d.CommandID
return ActionOutcome{State: "committed", Payload: map[string]any{"reply": "ok"}}, nil
})
res, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "builtin.help", Revision: f.rev, RequestID: "help-1", Data: map[string]any{}})
if err != nil || res.State != "committed" || got != "builtin.help" {
t.Fatalf("help action: got=%q res=%+v err=%v", got, res, err)
}
}
func TestSubmitActionGuards(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
+4 -2
View File
@@ -235,10 +235,12 @@ type ActionOutcome struct {
type Dispatcher func(ctx context.Context, d ActionDispatch) (ActionOutcome, error)
// AllowedActions 是动作 id → 命令 id 的白名单(U5 第一版仅插件自有提交;
// B 类命令逐条经独立评审后加入)。
// AllowedActions 是动作 id → 命令 id 的白名单(U5:C 类 submit + 经独立评审的 B 类)。
// 与 genui.AllowedActionIDs 保持同步;每加一条须 Documents 独立评审提案。
var AllowedActions = map[string]string{
"generative-ui.submit": "generative-ui.submit",
"builtin.help": "builtin.help",
"system.intent": "system.intent",
}
// SubmitActionRequest 是一次页面动作提交(按钮点击)。