diff --git a/internal/app/app.go b/internal/app/app.go index f1db8b5..a0f3ca3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 8945888..cf013b0 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -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 仍是热路径;重启后从存储取回。 diff --git a/internal/cmdbus/command_tools_test.go b/internal/cmdbus/command_tools_test.go index 1ba6cba..96f0b62 100644 --- a/internal/cmdbus/command_tools_test.go +++ b/internal/cmdbus/command_tools_test.go @@ -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) + } +} diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index 347308d..010a3d5 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -41,6 +41,9 @@ type AssembleOptions struct { // ThinkReplay 为真时,历史 assistant 消息的思考部件以 块原样回传 //(交错思考模型如 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() +} diff --git a/internal/ctxasm/surface_proj_test.go b/internal/ctxasm/surface_proj_test.go new file mode 100644 index 0000000..55e84d6 --- /dev/null +++ b/internal/ctxasm/surface_proj_test.go @@ -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 +} diff --git a/internal/genui/genui.go b/internal/genui/genui.go new file mode 100644 index 0000000..3a8f169 --- /dev/null +++ b/internal/genui/genui.go @@ -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 +} diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go new file mode 100644 index 0000000..2ec2331 --- /dev/null +++ b/internal/genui/genui_test.go @@ -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 } diff --git a/internal/genui/replay_test.go b/internal/genui/replay_test.go new file mode 100644 index 0000000..49ab46a --- /dev/null +++ b/internal/genui/replay_test.go @@ -0,0 +1,251 @@ +package genui + +// U3 完成条件回放(真实模型,env 门控):任务→规格→保存→(语义投影回灌)→ +// 继续修改→新修订。跑生产代码路径:present 处理器 + surfacesvc + store + ctxasm +// 投影(AssembleOptions.Surfaces)。模型 MiniMax-M3(2026-10-11 裁定)。 +// +// 启用:GENUI_MINIMAX_API_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 +} diff --git a/internal/runmgr/command_tools_test.go b/internal/runmgr/command_tools_test.go index 9ea10e6..1ac14b6 100644 --- a/internal/runmgr/command_tools_test.go +++ b/internal/runmgr/command_tools_test.go @@ -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 diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index a71eb1f..be01603 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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 } diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 1d0deb6..4c7e069 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -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{ diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 41b7ffe..79f599c 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -1117,3 +1117,5 @@ func transcriptOf(msgs []agentllm.Message) string { } return b.String() } + +func (f *fakeRunCtx) Surfaces() []core.SurfaceSnapshot { return nil } diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go index 9a7257d..d884842 100644 --- a/internal/runtime/runner.go +++ b/internal/runtime/runner.go @@ -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) } diff --git a/internal/runtime/squad_runner_test.go b/internal/runtime/squad_runner_test.go index f1bb76f..08e688e 100644 --- a/internal/runtime/squad_runner_test.go +++ b/internal/runtime/squad_runner_test.go @@ -461,3 +461,5 @@ func containsStr(list []string, want string) bool { } return false } + +func (*entryFake) Surfaces() []core.SurfaceSnapshot { return nil } diff --git a/internal/runtime/workflow_compiled_test.go b/internal/runtime/workflow_compiled_test.go index c783b65..134322d 100644 --- a/internal/runtime/workflow_compiled_test.go +++ b/internal/runtime/workflow_compiled_test.go @@ -291,3 +291,5 @@ func TestJudgeStreamUsesConfiguredModel(t *testing.T) { t.Fatal("未配置模型应为 nil(验证器不可用 fail-closed)") } } + +func (*wfFake) Surfaces() []core.SurfaceSnapshot { return nil }