Whitelist B-class surface actions for help and intent #167
+16
-5
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 是一次页面动作提交(按钮点击)。
|
||||
|
||||
Reference in New Issue
Block a user