Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages
ci / vet-and-test (pull_request) Successful in 1m4s

Why: MiniMax-M3 emits XML-RPC-style {"item": ...} single-key wrappers on
array fields (kv/cells/items), bare scalar table cells, and page
submission delegated to the general sub-agent had no generative-ui
tool - observed live on dev (events seq 1395/1396/2171) where every
present with kv/table/list nodes failed invalid-spec and the delegated
retry reported the tool missing.

Evidence: red test reproducing the exact production error
(.nodes.1.kv of type [][2]string) goes green after the repair; full
genui/prompts/runtime/runmgr suites pass (SELF); mismatched-length
cells stay untouched by a dedicated guard test.

Impact: generative-ui pages with kv/table/list nodes save successfully
again; the general sub-agent can submit pages (tool resolves only when
the plugin exposes it, so absent plugins are unaffected).

Refs: dev task card feature/genui-spec-repair; dev conversation
激活验证-1012.
This commit is contained in:
2026-10-12 03:03:26 +00:00
parent a80d3c3359
commit 3d850a282f
3 changed files with 185 additions and 2 deletions
+109
View File
@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"agent-system-backend/pkg/extension"
@@ -276,10 +277,118 @@ func normalizeSpecParam(v any) any {
}
}
repairChildrenShape(m)
// {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}}
// 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进
// 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。
if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok {
m = unwrapped
}
repairPairs(m)
repairTableCells(m)
return m
}
// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12
// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…)
// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。
// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义,
// 安全解包;只改结构不改内容。
func repairItemWrappers(v any) any {
switch t := v.(type) {
case map[string]any:
if len(t) == 1 {
if inner, ok := t["item"]; ok {
return repairItemWrappers(inner)
}
}
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = repairItemWrappers(val)
}
return out
case []any:
out := make([]any, len(t))
for i, item := range t {
out[i] = repairItemWrappers(item)
}
return out
default:
return v
}
}
// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与
// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。
// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动,
// 交校验器给字段级错误供模型修复重提。
func repairTableCells(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
if tbl, ok := m["table"].(map[string]any); ok {
cols, colsOK := tbl["columns"].([]any)
rows, rowsOK := tbl["rows"].([]any)
if colsOK && rowsOK {
keys := make([]string, len(cols))
valid := true
for i, c := range cols {
cm, ok := c.(map[string]any)
if !ok {
valid = false
break
}
k, ok := cm["key"].(string)
if !ok || k == "" {
valid = false
break
}
keys[i] = k
}
if valid {
for _, r := range rows {
rm, ok := r.(map[string]any)
if !ok {
continue
}
cells, ok := rm["cells"].([]any)
if !ok || len(cells) != len(keys) {
continue
}
paired := make([]any, 0, len(cells))
convertible := true
for i, c := range cells {
switch cv := c.(type) {
case string:
paired = append(paired, []any{keys[i], cv})
case float64:
paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)})
default:
convertible = false
break
}
}
if convertible {
rm["cells"] = paired
}
}
}
}
}
for _, v := range m {
if vm, ok := v.(map[string]any); ok {
repairTableCells(vm)
}
if va, ok := v.([]any); ok {
for _, item := range va {
if im, ok := item.(map[string]any); ok {
repairTableCells(im)
}
}
}
}
}
// repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测):
// children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。
func repairChildrenShape(node any) {
+73
View File
@@ -297,3 +297,76 @@ func TestNormalizeChildrenShape(t *testing.T) {
t.Fatalf("children should be repaired to array, got %v", ch)
}
}
// 真实偏差(2026-10-12 dev 实测,事件 seq 1395/2171):MiniMax-M3 对数组字段输出
// XML-RPC 风格 {"item": …} 包装——kv/cells/items 整字段被包一层 {"item":[…]},
// 数组元素再被包 {"item":["k","v"]}/{"item":"v"};表格行 cells 另有与列等长的
// 裸标量数组形态。修复层须解包并按列 key 配对,present 才能落库。
func TestPresentRepairsItemWrappers(t *testing.T) {
newRealSvc(t)
h := &fakeHost{enabled: true, epoch: 1}
spec := map[string]any{
"title": "本月支出结构分析(示例数据演示)",
"nodes": []any{
map[string]any{"type": "markdown", "id": "disclaimer", "markdown": "示例数据,非真实账单。"},
map[string]any{"type": "kv", "id": "kv1", "kv": map[string]any{"item": []any{
map[string]any{"item": []any{"月份", "2026-10"}},
map[string]any{"item": []any{"总支出", "¥10,000"}},
}}},
map[string]any{"type": "list", "id": "l1", "items": map[string]any{"item": []any{
map[string]any{"item": "餐饮 35%"},
map[string]any{"item": "住房 25%"},
}}},
map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{
map[string]any{"key": "cat", "label": "分类"},
map[string]any{"key": "amt", "label": "金额"},
map[string]any{"key": "pct", "label": "占比"},
},
"rows": []any{
map[string]any{"cells": map[string]any{"item": []any{
map[string]any{"item": "餐饮"}, map[string]any{"item": "¥3,500"}, map[string]any{"item": "35%"},
}}},
},
}},
},
}
res, err := present(context.Background(), h, reqWith(spec))
if err != nil {
t.Fatalf("present: %v", err)
}
if body, ok := res.(toolErrorBody); ok {
t.Fatalf("present should repair item wrappers, got %s %v", body.Error, body.Details)
}
if len(h.saved) != 1 {
t.Fatalf("expected one saved surface, got %d", len(h.saved))
}
var saved core.SurfaceSpec
if err := json.Unmarshal(h.saved[0].Spec, &saved); err != nil {
t.Fatalf("saved spec should unmarshal: %v", err)
}
if saved.Nodes[1].KV[0] != [2]string{"月份", "2026-10"} || saved.Nodes[1].KV[1][1] != "¥10,000" {
t.Fatalf("kv pairs should be unwrapped, got %v", saved.Nodes[1].KV)
}
if len(saved.Nodes[2].Items) != 2 || saved.Nodes[2].Items[0] != "餐饮 35%" {
t.Fatalf("list items should be unwrapped, got %v", saved.Nodes[2].Items)
}
cells := saved.Nodes[3].Table.Rows[0].Cells
if len(cells) != 3 || cells[0] != [2]string{"cat", "餐饮"} || cells[2][1] != "35%" {
t.Fatalf("table cells should pair with column keys, got %v", cells)
}
}
// 长度不匹配的裸 cells 不做发明性配对:保留原样交校验器给字段级错误。
func TestRepairTableCellsLeavesMismatchedShapes(t *testing.T) {
in := map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{map[string]any{"key": "a"}, map[string]any{"key": "b"}},
"rows": []any{map[string]any{"cells": []any{"x", "y", "z"}}},
}}
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
rows := out.(map[string]any)["nodes"].([]any)[0].(map[string]any)["table"].(map[string]any)["rows"].([]any)
got := rows[0].(map[string]any)["cells"].([]any)
if len(got) != 3 || got[0] != "x" {
t.Fatalf("mismatched scalars should stay untouched, got %v", got)
}
}
@@ -1,10 +1,10 @@
---
id: agent.sub.general
layer: agent
version: 2
version: 3
title: 通用子代理
description: 独立完成子任务并返回结构化结果(默认子代理提示词)
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, generative-ui.present
model: lightweight
---
你是被派生的子代理,负责独立完成一个子任务。
@@ -13,3 +13,4 @@ model: lightweight
- 把任务完整做完:既不过度修饰,也不半途而废;遇到障碍先自行尝试解决,确实无法解决时在结果中说明卡点与已尝试的手段。
- 只使用与任务相关的信息,不臆测派生方未提供的上下文。
- 完成时返回结构化结果:做了什么、关键发现/结论、产出物位置(如有)、遗留问题(如有)。分点列出,关键内容不省略;摘要长度服从内容,不为简短而丢信息。
- 页面化产物(分析页/对比页等)用 `generative-ui.present` 提交;工具不可用或提交被拒时改用 `write_artifact` 落盘并在结果中说明。