Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages
ci / vet-and-test (pull_request) Successful in 1m4s
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:
@@ -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) {
|
||||
|
||||
@@ -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` 落盘并在结果中说明。
|
||||
|
||||
Reference in New Issue
Block a user