Add the generative-ui plugin, capability gating, and surface projection #149
+16
-1
@@ -34,6 +34,7 @@ import (
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/genui"
|
||||
"agent-system-backend/internal/hostapi"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentsem"
|
||||
@@ -493,6 +494,16 @@ func Run(opt Options) error {
|
||||
|
||||
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
|
||||
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
|
||||
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
|
||||
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
|
||||
bus.SetToolGate(func(owner, commandID string) bool {
|
||||
if !strings.HasPrefix(commandID, genui.PluginID+".") {
|
||||
return true
|
||||
}
|
||||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||||
return err == nil && row.Enabled
|
||||
})
|
||||
|
||||
bus.SetPendingStore(st)
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
@@ -824,7 +835,11 @@ func splitKeyPool(s string) []string {
|
||||
type busAdapter struct{ bus *cmdbus.Bus }
|
||||
|
||||
func (a busAdapter) CommandTools() []runmgr.CommandTool {
|
||||
src := a.bus.CommandTools()
|
||||
return a.CommandToolsFor("")
|
||||
}
|
||||
|
||||
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
|
||||
src := a.bus.CommandToolsFor(owner)
|
||||
out := make([]runmgr.CommandTool, len(src))
|
||||
for i, ct := range src {
|
||||
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
|
||||
|
||||
@@ -45,12 +45,28 @@ type CommandTool struct {
|
||||
}
|
||||
|
||||
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
|
||||
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
|
||||
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
|
||||
type ToolGate func(owner, commandID string) bool
|
||||
|
||||
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
|
||||
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
|
||||
|
||||
func (b *Bus) CommandTools() []CommandTool {
|
||||
return b.CommandToolsFor("")
|
||||
}
|
||||
|
||||
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
|
||||
// gated 命令一律不投影——fail-closed)。
|
||||
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
|
||||
out := []CommandTool{}
|
||||
for _, r := range b.reg.List() {
|
||||
if !r.Enabled || r.Spec.ExposeAsTool == nil {
|
||||
continue
|
||||
}
|
||||
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
|
||||
continue
|
||||
}
|
||||
risk := r.Spec.ExposeAsTool.Risk
|
||||
if risk == "" {
|
||||
risk = "low"
|
||||
@@ -101,6 +117,7 @@ type Bus struct {
|
||||
pending map[string]pending
|
||||
pendingStore PendingStore
|
||||
intents *IntentStore // G2-2:意图声明表(自然语言路由)
|
||||
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
|
||||
}
|
||||
|
||||
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
|
||||
|
||||
@@ -6,6 +6,7 @@ package cmdbus
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -72,3 +73,30 @@ func TestCommandToolDispatchEnvelope(t *testing.T) {
|
||||
type nopAudit struct{}
|
||||
|
||||
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
|
||||
|
||||
// U3:owner 级注入闸——gate 拒绝的命令不投影(禁用即不注入能力);fail-closed
|
||||
// (owner 为空且 gate 判否时不投影)。
|
||||
func TestCommandToolsForGate(t *testing.T) {
|
||||
bus := New(nil, nil, func() time.Time { return time.Now().UTC() })
|
||||
if err := bus.RegisterPluginCommands([]json.RawMessage{
|
||||
[]byte(`{"id":"generative-ui.present","title":"生成页面","description":"d","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
|
||||
[]byte(`{"id":"other.echo","title":"回声","description":"e","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus.SetToolGate(func(owner, id string) bool {
|
||||
return !strings.HasPrefix(id, "generative-ui.")
|
||||
})
|
||||
on := bus.CommandToolsFor("alice")
|
||||
for _, ct := range on {
|
||||
if strings.HasPrefix(ct.ID, "generative-ui.") {
|
||||
t.Fatalf("gated command leaked for enabled owner: %+v", ct)
|
||||
}
|
||||
}
|
||||
if len(on) != 1 {
|
||||
t.Fatalf("expected only other.echo, got %+v", on)
|
||||
}
|
||||
if n := len(bus.CommandToolsFor("")); n != 1 {
|
||||
t.Fatalf("empty owner must be fail-closed on gated commands, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ type AssembleOptions struct {
|
||||
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
|
||||
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
|
||||
ThinkReplay bool
|
||||
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
|
||||
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
|
||||
Surfaces []core.SurfaceSnapshot
|
||||
}
|
||||
|
||||
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
|
||||
@@ -58,6 +61,17 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
|
||||
rep.Items = append(rep.Items, "system(画像自定义段)")
|
||||
}
|
||||
if len(opts.Surfaces) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
|
||||
for _, snap := range opts.Surfaces {
|
||||
b.WriteString(projectSurface(snap))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
|
||||
}
|
||||
|
||||
maxHist := prof.Context.MaxHistoryMessages
|
||||
if maxHist <= 0 {
|
||||
@@ -499,3 +513,42 @@ func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm
|
||||
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||||
return out
|
||||
}
|
||||
|
||||
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
|
||||
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
|
||||
func projectSurface(snap core.SurfaceSnapshot) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
|
||||
if snap.SemanticText != "" {
|
||||
fmt.Fprintf(&b, ":%s", snap.SemanticText)
|
||||
}
|
||||
b.WriteString("\n 结构:")
|
||||
var walk func(n core.UINode)
|
||||
first := true
|
||||
walk = func(n core.UINode) {
|
||||
label := string(n.Type)
|
||||
if n.Title != "" {
|
||||
label += "(\"" + n.Title + "\")"
|
||||
}
|
||||
if n.ID != "" {
|
||||
label += "#" + n.ID
|
||||
}
|
||||
if len(n.Source) > 0 {
|
||||
label += "[" + strings.Join(n.Source, ",") + "]"
|
||||
}
|
||||
if first {
|
||||
b.WriteString(label)
|
||||
first = false
|
||||
} else {
|
||||
b.WriteString(" > " + label)
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, n := range snap.Spec.Nodes {
|
||||
walk(n)
|
||||
}
|
||||
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package ctxasm
|
||||
|
||||
// U3/R6:已保存页面的语义投影并入上下文——下一轮模型能看到结构、来源与修订
|
||||
// 入口(present + baseRevision),可据此解释并修改已有 table/compare/tabs。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func surfaceSnap() core.SurfaceSnapshot {
|
||||
return core.SurfaceSnapshot{
|
||||
Identity: core.SurfaceIdentity{SurfaceID: "surf_x", Owner: "alice", PluginID: "generative-ui", ConversationID: "c1"},
|
||||
Revision: 2,
|
||||
Spec: core.SurfaceSpec{Title: "选型对比", Nodes: []core.UINode{
|
||||
{Type: core.UISection, ID: "n1", Title: "概览", Children: []core.UINode{
|
||||
{Type: core.UIMarkdown, ID: "n1a", Source: []string{"doc:a"}, Markdown: "结论"},
|
||||
}},
|
||||
{Type: core.UITable, ID: "n2", Source: []string{"doc:a", "doc:b"}},
|
||||
}},
|
||||
SemanticText: "三工具对比页",
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurfaceProjectionInContext(t *testing.T) {
|
||||
msgs, rep := AssembleInput(core.AgentProfile{}, nil, nil,
|
||||
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改一下"}}},
|
||||
nil, nil, nil, nil, nil, nil, AssembleOptions{Surfaces: []core.SurfaceSnapshot{surfaceSnap()}})
|
||||
joined := make([]string, len(msgs))
|
||||
for i, m := range msgs {
|
||||
joined[i] = m.Content
|
||||
}
|
||||
all := strings.Join(joined, "\n---\n")
|
||||
for _, need := range []string{"surf_x", "修订 2", "选型对比", "section(\"概览\")#n1", "markdown#n1a[doc:a]", "table#n2", "baseRevision=2"} {
|
||||
if !strings.Contains(all, need) {
|
||||
t.Fatalf("projection missing %q in assembled context:\n%s", need, all)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, it := range rep.Items {
|
||||
if strings.HasPrefix(it, "surface(") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("report should count surface injection: %v", rep.Items)
|
||||
}
|
||||
_ = agentllm.RoleSystem
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
|
||||
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
|
||||
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
|
||||
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
|
||||
package genui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// PluginID 是插件身份(activation 台账与命令前缀共用)。
|
||||
const PluginID = "generative-ui"
|
||||
|
||||
// CommandPresent 是唯一提交命令 id(revise 即带 surfaceId+baseRevision 的再次 present)。
|
||||
const CommandPresent = PluginID + ".present"
|
||||
|
||||
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
|
||||
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
|
||||
const Catalog = `可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
|
||||
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
|
||||
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
|
||||
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
|
||||
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
|
||||
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
|
||||
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
|
||||
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
|
||||
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
|
||||
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
|
||||
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
|
||||
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
|
||||
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
|
||||
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
|
||||
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
|
||||
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
|
||||
{"title":"示例页","nodes":[
|
||||
{"type":"section","id":"n1","title":"结论","children":[
|
||||
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
|
||||
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
|
||||
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
|
||||
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
|
||||
|
||||
func init() {
|
||||
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
|
||||
}
|
||||
|
||||
func register(reg extension.Registry) error {
|
||||
spec := json.RawMessage(fmt.Sprintf(`{
|
||||
"id": %q,
|
||||
"title": "生成页面",
|
||||
"description": %q,
|
||||
"paramsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
|
||||
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
|
||||
"required": ["title", "nodes"]},
|
||||
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
|
||||
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
|
||||
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
|
||||
},
|
||||
"required": ["spec"]
|
||||
},
|
||||
"exposeAsTool": {"risk": "low"}
|
||||
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
|
||||
return reg.AddCommandHandler(spec, present)
|
||||
}
|
||||
|
||||
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
|
||||
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
|
||||
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
|
||||
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
sh, ok := h.(extension.SurfaceHost)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
|
||||
}
|
||||
enabled, epoch, err := sh.SurfaceActivation(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enabled {
|
||||
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
|
||||
}
|
||||
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
|
||||
surfaceID, _ := req.Params["surfaceId"].(string)
|
||||
base := 0
|
||||
if v, ok := req.Params["baseRevision"].(float64); ok {
|
||||
base = int(v)
|
||||
}
|
||||
summary, _ := req.Params["summary"].(string)
|
||||
|
||||
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
|
||||
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
|
||||
return toolError("invalid spec", errs), nil
|
||||
}
|
||||
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
|
||||
SurfaceID: core.ID(surfaceID),
|
||||
ConversationID: core.ID(req.ConversationID),
|
||||
BaseRevision: base,
|
||||
ObservedEpoch: epoch,
|
||||
SemanticText: summary,
|
||||
Spec: raw,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
|
||||
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
|
||||
var list []string
|
||||
for _, d := range strings.Split(details, ";") {
|
||||
if t := strings.TrimSpace(d); t != "" {
|
||||
list = append(list, t)
|
||||
}
|
||||
}
|
||||
return toolError("invalid spec", list), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"surfaceId": string(snap.Identity.SurfaceID),
|
||||
"revision": snap.Revision,
|
||||
"epoch": snap.ActivationEpoch,
|
||||
"summary": snap.SemanticText,
|
||||
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
|
||||
type toolErrorBody struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func toolError(msg string, details []string) toolErrorBody {
|
||||
return toolErrorBody{OK: false, Error: msg, Details: details,
|
||||
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
|
||||
}
|
||||
|
||||
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
|
||||
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
|
||||
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
|
||||
// 改写,不改内容语义;修复后仍过 core 校验器。
|
||||
func normalizeSpecParam(v any) any {
|
||||
if arr, ok := v.([]any); ok {
|
||||
return map[string]any{"title": "", "nodes": arr}
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
if len(m) == 1 {
|
||||
if inner, ok := m["page"].(map[string]any); ok {
|
||||
m = inner
|
||||
}
|
||||
}
|
||||
if _, ok := m["nodes"]; !ok {
|
||||
if ch, ok := m["children"].([]any); ok {
|
||||
m["nodes"] = ch
|
||||
delete(m, "children")
|
||||
}
|
||||
}
|
||||
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
|
||||
// 信息,不发明内容;节点自带 source 者不受影响。
|
||||
if top, ok := m["source"].([]any); ok {
|
||||
delete(m, "source")
|
||||
if nodes, ok := m["nodes"].([]any); ok {
|
||||
for _, n := range nodes {
|
||||
if nm, ok := n.(map[string]any); ok {
|
||||
if _, has := nm["source"]; !has {
|
||||
cp := make([]any, len(top))
|
||||
copy(cp, top)
|
||||
nm["source"] = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
repairPairs(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
|
||||
func repairPairs(node any) {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for key, val := range m {
|
||||
if (key == "kv" || key == "cells") && val != nil {
|
||||
if arr, ok := val.([]any); ok {
|
||||
fixed := make([]any, 0, len(arr))
|
||||
changed := false
|
||||
for _, item := range arr {
|
||||
if p, ok := pairFromObject(item); ok {
|
||||
fixed = append(fixed, p)
|
||||
changed = true
|
||||
} else {
|
||||
fixed = append(fixed, item)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
m[key] = fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
repairPairs(val)
|
||||
}
|
||||
}
|
||||
|
||||
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
|
||||
func pairFromObject(item any) ([2]any, bool) {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return [2]any{}, false
|
||||
}
|
||||
var k, v any
|
||||
for kk, vv := range obj {
|
||||
switch kk {
|
||||
case "k", "key", "name":
|
||||
k = vv
|
||||
case "v", "value":
|
||||
v = vv
|
||||
default:
|
||||
return [2]any{}, false
|
||||
}
|
||||
}
|
||||
if k == nil || v == nil {
|
||||
return [2]any{}, false
|
||||
}
|
||||
ks, ok1 := k.(string)
|
||||
vs, ok2 := v.(string)
|
||||
if !ok1 || !ok2 {
|
||||
return [2]any{}, false
|
||||
}
|
||||
return [2]any{ks, vs}, true
|
||||
}
|
||||
|
||||
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
|
||||
func authorizeDocRefs(raw []byte, h extension.Host) []string {
|
||||
var spec struct {
|
||||
Nodes []core.UINode `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
return []string{"spec: " + err.Error()}
|
||||
}
|
||||
var errs []string
|
||||
var walk func(n core.UINode, path string)
|
||||
walk = func(n core.UINode, path string) {
|
||||
if n.Type == core.UIDocRef && n.DocID != "" {
|
||||
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
|
||||
}
|
||||
}
|
||||
for i, c := range n.Children {
|
||||
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
|
||||
}
|
||||
if n.Compare != nil {
|
||||
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||||
for i, c := range sd.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Tabs != nil {
|
||||
for i, tb := range n.Tabs.Tabs {
|
||||
for j, c := range tb.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, n := range spec.Nodes {
|
||||
walk(n, fmt.Sprintf("$[%d]", i))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package genui
|
||||
|
||||
// U3 红测(提案 U3 验证行):能力缺失明确拒绝;未启用拒绝;伪造 doc-ref 拒绝;
|
||||
// 目录违规返回字段级清单(供模型修复重提);成功路径经 EXT-17 提交并携带观察 epoch。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// realSvc 让 fakeHost.SurfaceSave 走真实 surfacesvc(校验/守卫行为与生产一致)。
|
||||
var realSvc *surfacesvc.Service
|
||||
|
||||
func newRealSvc(t *testing.T) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
realSvc = surfacesvc.New(st, nil)
|
||||
if _, err := realSvc.SetActivation(context.Background(), "alice", PluginID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeHost struct {
|
||||
extension.Host // 未覆盖方法仅测试未触达
|
||||
enabled bool
|
||||
epoch int64
|
||||
saved []extension.SurfaceSaveRequest
|
||||
artifacts map[string]bool
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
return h.enabled, h.epoch, nil
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
h.saved = append(h.saved, req)
|
||||
return realSvc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID,
|
||||
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
|
||||
SemanticText: req.SemanticText, Spec: req.Spec,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
|
||||
return realSvc.Get(ctx, "alice", surfaceID)
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
|
||||
return realSvc.List(ctx, "alice", conversationID)
|
||||
}
|
||||
|
||||
func (h *fakeHost) ArtifactGet(id string) (core.Artifact, error) {
|
||||
if h.artifacts[id] {
|
||||
return core.Artifact{ID: core.ID(id)}, nil
|
||||
}
|
||||
return core.Artifact{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func validSpec() map[string]any {
|
||||
return map[string]any{
|
||||
"title": "选型对比",
|
||||
"nodes": []any{
|
||||
map[string]any{"type": "section", "id": "n1", "title": "概览", "children": []any{
|
||||
map[string]any{"type": "markdown", "id": "n1a", "source": []any{"doc:a"}, "markdown": "结论先行。"},
|
||||
}},
|
||||
map[string]any{"type": "table", "id": "n2", "table": map[string]any{
|
||||
"columns": []any{map[string]any{"key": "k", "label": "维度"}},
|
||||
"rows": []any{map[string]any{"cells": []any{[]any{"k", "价格"}}}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reqWith(spec map[string]any, extra ...map[string]any) extension.Request {
|
||||
params := map[string]any{"spec": spec}
|
||||
for _, e := range extra {
|
||||
for k, v := range e {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
return extension.Request{ID: "r1", Params: params, Owner: "alice", ConversationID: "c1"}
|
||||
}
|
||||
|
||||
func TestPresentGuards(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
// 能力缺失(Host 未实现 SurfaceHost)→ 明确报错
|
||||
if _, err := present(ctx, plainHost{}, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("capability-missing host should fail explicitly")
|
||||
}
|
||||
// 未启用 → 拒绝
|
||||
h := &fakeHost{enabled: false, artifacts: map[string]bool{}}
|
||||
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("disabled plugin should reject submit")
|
||||
}
|
||||
// 伪造 doc-ref:工件不存在 → 字段级工具错误
|
||||
h = &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{"type": "doc-ref", "docId": "art_ghost"}}}
|
||||
res, err := present(ctx, h, reqWith(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("doc-ref violation should be tool-error, got %v", err)
|
||||
}
|
||||
body, _ := json.Marshal(res)
|
||||
if !strings.Contains(string(body), "art_ghost") {
|
||||
t.Fatalf("field errors should name the bad docId: %s", body)
|
||||
}
|
||||
// 目录违规(未知字段 color)→ 字段级清单
|
||||
bad := map[string]any{"title": "x", "nodes": []any{map[string]any{"type": "list", "items": []any{"a"}, "color": "red"}}}
|
||||
res, err = present(ctx, h, reqWith(bad))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid spec should be tool-error, got %v", err)
|
||||
}
|
||||
te, ok := res.(toolErrorBody)
|
||||
if !ok || te.OK || len(te.Details) == 0 {
|
||||
t.Fatalf("expected field-level details, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentSubmitCarriesEpochAndRevises(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
res, err := present(ctx, h, reqWith(validSpec()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := res.(map[string]any)
|
||||
if m["ok"] != true || fmt.Sprint(m["revision"]) != "1" {
|
||||
t.Fatalf("submit result: %+v", m)
|
||||
}
|
||||
if len(h.saved) != 1 || h.saved[0].ObservedEpoch != 1 {
|
||||
t.Fatalf("submit must carry observed epoch: %+v", h.saved)
|
||||
}
|
||||
// 修订:带 surfaceId + baseRevision → revision 2
|
||||
res2, err := present(ctx, h, reqWith(validSpec(), map[string]any{
|
||||
"surfaceId": m["surfaceId"], "baseRevision": float64(1)}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2, _ := res2.(map[string]any)
|
||||
if fmt.Sprint(m2["revision"]) != "2" {
|
||||
t.Fatalf("revision should advance to 2: %+v", m2)
|
||||
}
|
||||
// 禁用后:执行端拒绝(闸外第二道守卫)
|
||||
h.enabled = false
|
||||
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("post-disable submit should reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSpecShape(t *testing.T) {
|
||||
var captured json.RawMessage
|
||||
reg := &fakeRegistry{capture: &captured}
|
||||
if err := register(reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var spec struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
ExposeAsTool map[string]any `json:"exposeAsTool"`
|
||||
ParamsSchema map[string]any `json:"paramsSchema"`
|
||||
}
|
||||
if err := json.Unmarshal(captured, &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
|
||||
t.Fatalf("spec must expose as tool: %s", captured)
|
||||
}
|
||||
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点"} {
|
||||
if !strings.Contains(spec.Description, need) {
|
||||
t.Fatalf("catalog incomplete: missing %q", need)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type plainHost struct{ extension.Host }
|
||||
|
||||
type fakeRegistry struct{ capture *json.RawMessage }
|
||||
|
||||
func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
*r.capture = spec
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddSkill(name, markdown string) error { return nil }
|
||||
func (r *fakeRegistry) AddCommand(spec json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,251 @@
|
||||
package genui
|
||||
|
||||
// U3 完成条件回放(真实模型,env 门控):任务→规格→保存→(语义投影回灌)→
|
||||
// 继续修改→新修订。跑生产代码路径:present 处理器 + surfacesvc + store + ctxasm
|
||||
// 投影(AssembleOptions.Surfaces)。模型 MiniMax-M3(2026-10-11 裁定)。
|
||||
//
|
||||
// 启用:GENUI_MINIMAX_API_KEY=<key> go test ./internal/genui/ -run TestRealModelSurfaceLoop -v -count=1
|
||||
// 未设 env 时跳过(CI 无凭据;本地收据经 make evidence 记录)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const materials = `材料:
|
||||
【doc:m1】甲方案:报价 ¥8/人/天;私有化可选;近半年发布 6 次,平均修复 2 天。
|
||||
【doc:m2】乙方案:报价 ¥12/人/天;仅 SaaS;万行表格场景无官方基准;升级断层(近两季仅小版本)。
|
||||
【doc:m3】尽调备忘:合规缺口(高)——数据出境条款未覆盖;培训成本(低)——术语不一致。`
|
||||
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
func callModelWithRepair(t *testing.T, ctx context.Context, system, user string, try func(map[string]any) (any, error)) (map[string]any, any, chatResp) {
|
||||
t.Helper()
|
||||
var usage chatResp
|
||||
params, r := callModel(t, ctx, system, user)
|
||||
usage.Usage.PromptTokens += r.Usage.PromptTokens
|
||||
usage.Usage.CompletionTokens += r.Usage.CompletionTokens
|
||||
res, err := try(params)
|
||||
if err == nil {
|
||||
if te, ok := res.(toolErrorBody); !ok || te.OK {
|
||||
return params, res, usage
|
||||
} else {
|
||||
res = te
|
||||
}
|
||||
} else if res == nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
// 修复轮:字段级错误回传,模型重提一次(KTD2)。
|
||||
fixUser := user + "\n\n上一次提交被拒绝:" + fmt.Sprint(res) + "\n请修正后重新调用工具。"
|
||||
params2, r2b := callModel(t, ctx, system, fixUser)
|
||||
usage.Usage.PromptTokens += r2b.Usage.PromptTokens
|
||||
usage.Usage.CompletionTokens += r2b.Usage.CompletionTokens
|
||||
res2, err2 := try(params2)
|
||||
if err2 != nil {
|
||||
t.Fatalf("repair dispatch error: %v", err2)
|
||||
}
|
||||
if te, ok := res2.(toolErrorBody); ok && !te.OK {
|
||||
t.Fatalf("repair still invalid: %+v", te)
|
||||
}
|
||||
return params2, res2, usage
|
||||
}
|
||||
|
||||
func callModel(t *testing.T, ctx context.Context, system, user string) (map[string]any, chatResp) {
|
||||
t.Helper()
|
||||
key := os.Getenv("GENUI_MINIMAX_API_KEY")
|
||||
tool := map[string]any{"type": "function", "function": map[string]any{
|
||||
"name": CommandPresent, "description": Catalog, "parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"spec": map[string]any{"type": "object"},
|
||||
"surfaceId": map[string]any{"type": "string"},
|
||||
"baseRevision": map[string]any{"type": "integer"},
|
||||
"summary": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"spec"},
|
||||
}}}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": "MiniMax-M3", "temperature": 0.3, "max_tokens": 14000,
|
||||
"tools": []any{tool}, "tool_choice": map[string]any{"type": "function", "function": map[string]any{"name": CommandPresent}},
|
||||
"messages": []any{
|
||||
map[string]any{"role": "system", "content": system},
|
||||
map[string]any{"role": "user", "content": user},
|
||||
}})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.minimaxi.com/v1/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var out chatResp
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("model call: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out.Choices) == 0 || len(out.Choices[0].Message.ToolCalls) == 0 {
|
||||
t.Fatalf("no tool call in response")
|
||||
}
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal([]byte(out.Choices[0].Message.ToolCalls[0].Function.Arguments), ¶ms); err != nil {
|
||||
t.Fatalf("tool args: %v", err)
|
||||
}
|
||||
return params, out
|
||||
}
|
||||
|
||||
func TestRealModelSurfaceLoop(t *testing.T) {
|
||||
if os.Getenv("GENUI_MINIMAX_API_KEY") == "" {
|
||||
t.Skip("GENUI_MINIMAX_API_KEY not set; real-model replay runs locally with recorded receipts")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "replay.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
svc := surfacesvc.New(st, nil)
|
||||
if _, err := svc.SetActivation(ctx, "alice", PluginID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &realHost{svc: svc, artifacts: map[string]bool{}}
|
||||
|
||||
// 回合 1:任务→规格→保存
|
||||
sys1 := "你是本产品中的助手。用户需要一页比较视图时,用 generative-ui.present 提交页面。"
|
||||
try1 := func(p map[string]any) (any, error) {
|
||||
return present(ctx, h, extension.Request{ID: "t1", Params: p, Owner: "alice", ConversationID: "c1"})
|
||||
}
|
||||
_, res1, r1 := callModelWithRepair(t, ctx, sys1, materials+"\n\n目标:给决策会准备风险分析页(按严重度组织,标注来源)。", try1)
|
||||
m1, _ := res1.(map[string]any)
|
||||
if m1["ok"] != true {
|
||||
t.Fatalf("turn1 not accepted: %+v", res1)
|
||||
}
|
||||
snap1, err := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("turn1: surface=%s revision=%d nodes=%d tokens=%d/%d",
|
||||
m1["surfaceId"], snap1.Revision, countNodes(snap1), r1.Usage.PromptTokens, r1.Usage.CompletionTokens)
|
||||
|
||||
// 回合 2:语义投影回灌 + 修订指令 → 新修订
|
||||
msgs2, _ := ctxasm.AssembleInput(core.AgentProfile{}, nil, nil,
|
||||
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改为实施顺序"}}},
|
||||
nil, nil, nil, nil, nil, nil, ctxasm.AssembleOptions{Surfaces: []core.SurfaceSnapshot{snap1}})
|
||||
var sysParts []string
|
||||
for _, m := range msgs2 {
|
||||
if string(m.Role) == "system" {
|
||||
sysParts = append(sysParts, m.Content)
|
||||
}
|
||||
}
|
||||
sys2 := strings.Join(sysParts, "\n")
|
||||
try2 := func(p map[string]any) (any, error) {
|
||||
p["surfaceId"] = m1["surfaceId"]
|
||||
p["baseRevision"] = float64(snap1.Revision)
|
||||
return present(ctx, h, extension.Request{ID: "t2", Params: p, Owner: "alice", ConversationID: "c1"})
|
||||
}
|
||||
_, res2, r2 := callModelWithRepair(t, ctx, sys1+"\n"+sys2, materials+"\n\n目标:改为实施顺序(阶段与依赖),修订已有页面。", try2)
|
||||
m2, _ := res2.(map[string]any)
|
||||
if fmt.Sprint(m2["revision"]) != fmt.Sprint(snap1.Revision+1) {
|
||||
t.Fatalf("turn2 should revise to %d, got %+v", snap1.Revision+1, res2)
|
||||
}
|
||||
snap2, _ := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
|
||||
types2 := dominantTypes(snap2)
|
||||
same := len(types2) == len(snap1.Spec.Nodes)
|
||||
if same {
|
||||
for i := range types2 {
|
||||
if types2[i] != string(snap1.Spec.Nodes[i].Type) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snap2.Spec.Nodes) == 0 || (snap2.Spec.Title == snap1.Spec.Title && same) {
|
||||
t.Fatalf("turn2 structure did not change: %v", types2)
|
||||
}
|
||||
t.Logf("turn2: revision=%d title=%q types=%v tokens=%d/%d",
|
||||
snap2.Revision, snap2.Spec.Title, types2, r2.Usage.PromptTokens, r2.Usage.CompletionTokens)
|
||||
totalIn := r1.Usage.PromptTokens + r2.Usage.PromptTokens
|
||||
totalOut := r1.Usage.CompletionTokens + r2.Usage.CompletionTokens
|
||||
cost := float64(totalIn)/1e6*0.3*7.2 + float64(totalOut)/1e6*1.2*7.2
|
||||
t.Logf("cost: in=%d out=%d ≈¥%.3f (M3 $0.3/$1.2 per M, 7.2)", totalIn, totalOut, cost)
|
||||
}
|
||||
|
||||
type realHost struct {
|
||||
extension.Host
|
||||
svc *surfacesvc.Service
|
||||
artifacts map[string]bool
|
||||
}
|
||||
|
||||
func (h *realHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
row, err := h.svc.Activation(ctx, "alice", PluginID)
|
||||
return row.Enabled, row.Epoch, err
|
||||
}
|
||||
func (h *realHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
return h.svc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, BaseRevision: req.BaseRevision,
|
||||
ObservedEpoch: req.ObservedEpoch, SemanticText: req.SemanticText, Spec: req.Spec})
|
||||
}
|
||||
func (h *realHost) SurfaceGet(ctx context.Context, id core.ID) (core.SurfaceSnapshot, error) {
|
||||
return h.svc.Get(ctx, "alice", id)
|
||||
}
|
||||
func (h *realHost) SurfaceList(ctx context.Context, cid core.ID) ([]core.SurfaceMeta, error) {
|
||||
return h.svc.List(ctx, "alice", cid)
|
||||
}
|
||||
func (h *realHost) ArtifactGet(id string) (core.Artifact, error) {
|
||||
if h.artifacts[id] {
|
||||
return core.Artifact{ID: core.ID(id)}, nil
|
||||
}
|
||||
return core.Artifact{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func countNodes(s core.SurfaceSnapshot) int {
|
||||
var n int
|
||||
var walk func(u core.UINode)
|
||||
walk = func(u core.UINode) {
|
||||
n++
|
||||
for _, c := range u.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, x := range s.Spec.Nodes {
|
||||
walk(x)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func dominantTypes(s core.SurfaceSnapshot) []string {
|
||||
var out []string
|
||||
for _, n := range s.Spec.Nodes {
|
||||
out = append(out, string(n.Type))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type fakeBusSource struct {
|
||||
|
||||
func (f *fakeBusSource) CommandTools() []CommandTool { return f.cmds }
|
||||
|
||||
func (f *fakeBusSource) CommandToolsFor(owner string) []CommandTool { return f.cmds }
|
||||
|
||||
func (f *fakeBusSource) DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse {
|
||||
f.lastReq = req
|
||||
return f.resp
|
||||
|
||||
@@ -203,6 +203,8 @@ func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
|
||||
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
|
||||
type CommandSource interface {
|
||||
CommandTools() []CommandTool
|
||||
// CommandToolsFor 按 owner 过滤工具投影(U3:禁用即不注入能力)。
|
||||
CommandToolsFor(owner string) []CommandTool
|
||||
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
|
||||
}
|
||||
|
||||
@@ -258,6 +260,28 @@ type runCtx struct {
|
||||
permissionMode string
|
||||
}
|
||||
|
||||
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌;
|
||||
// 最近 5 页、新→旧;读路径不受启禁影响——禁用挡写入,不丢历史)。
|
||||
func (c *runCtx) Surfaces() []core.SurfaceSnapshot {
|
||||
if c.mgr.st == nil || c.run.ConversationID == "" || c.run.Owner == "" {
|
||||
return nil
|
||||
}
|
||||
metas, err := c.mgr.st.ListSurfaces(string(c.run.Owner), string(c.run.ConversationID))
|
||||
if err != nil || len(metas) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(metas) > 5 {
|
||||
metas = metas[:5]
|
||||
}
|
||||
out := make([]core.SurfaceSnapshot, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
if snap, err := c.mgr.st.GetSurfaceSnapshot(string(c.run.Owner), string(m.SurfaceID), 0); err == nil {
|
||||
out = append(out, snap)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
|
||||
func (c *runCtx) Workflow() (workflow.Launch, bool) {
|
||||
if c.launch == nil {
|
||||
@@ -437,7 +461,7 @@ func (c *runCtx) Tools() []core.ToolDesc {
|
||||
for _, t := range list {
|
||||
seen[t.Name] = true
|
||||
}
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
|
||||
if seen[ct.ID] {
|
||||
continue
|
||||
}
|
||||
@@ -874,7 +898,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||||
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
|
||||
if c.mgr.commands != nil {
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
|
||||
if ct.ID != call.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
// 组装侧不再注入(防双份;注入文案同一形态)。
|
||||
_ = ctxasm.InjectMemory
|
||||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
|
||||
Surfaces: rc.Surfaces(),
|
||||
ThinkReplay: thinkReplayEnabled(eff, route.Model),
|
||||
})
|
||||
tail := ctxasm.Tail{
|
||||
|
||||
@@ -1117,3 +1117,5 @@ func transcriptOf(msgs []agentllm.Message) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) Surfaces() []core.SurfaceSnapshot { return nil }
|
||||
|
||||
@@ -50,6 +50,8 @@ type RunCtx interface {
|
||||
Card(id string) *core.ActivityCard
|
||||
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;无则 false)。
|
||||
Artifact(id string) (core.Artifact, []byte, bool)
|
||||
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌)。
|
||||
Surfaces() []core.SurfaceSnapshot
|
||||
// SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。
|
||||
SetContextObservation(o core.ContextObservation)
|
||||
}
|
||||
|
||||
@@ -461,3 +461,5 @@ func containsStr(list []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (*entryFake) Surfaces() []core.SurfaceSnapshot { return nil }
|
||||
|
||||
@@ -291,3 +291,5 @@ func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
|
||||
t.Fatal("未配置模型应为 nil(验证器不可用 fail-closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func (*wfFake) Surfaces() []core.SurfaceSnapshot { return nil }
|
||||
|
||||
Reference in New Issue
Block a user