Fix bizMonitor tool results failing DSH lossless JSON (strip undefined holes).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-20 15:35:08 +08:00
parent b235957c34
commit 4dd628b7cc
13 changed files with 414 additions and 136 deletions

View file

@ -737,6 +737,32 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args);
return toLosslessJson(await execute(args));
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -1481,7 +1507,7 @@ function taskBriefNe(brief) {
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -1498,7 +1524,7 @@ function slimMigrationProject(row) {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
};
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) {
tasks = tasks.slice(0, limit);
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) {
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
}
})
};
}
function slimCutoverBatch(row) {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
};
});
}
function slimCollectBatch(row, taskId = "") {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
};
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -1624,7 +1650,7 @@ function slimEvalRun(row) {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
};
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) {
}
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) {
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
}
})
};
}
async function getBizMonitorContext(client, args, signal) {
@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
if (result.ok === false) {
throw new Error(JSON.stringify(result));
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
}
return result;
return safe;
}
});
}

View file

@ -737,6 +737,32 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args);
return toLosslessJson(await execute(args));
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -1481,7 +1507,7 @@ function taskBriefNe(brief) {
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -1498,7 +1524,7 @@ function slimMigrationProject(row) {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
};
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) {
tasks = tasks.slice(0, limit);
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) {
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
}
})
};
}
function slimCutoverBatch(row) {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
};
});
}
function slimCollectBatch(row, taskId = "") {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
};
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -1624,7 +1650,7 @@ function slimEvalRun(row) {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
};
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) {
}
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) {
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
}
})
};
}
async function getBizMonitorContext(client, args, signal) {
@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
if (result.ok === false) {
throw new Error(JSON.stringify(result));
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
}
return result;
return safe;
}
});
}

View file

@ -737,6 +737,32 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args);
return toLosslessJson(await execute(args));
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -1481,7 +1507,7 @@ function taskBriefNe(brief) {
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -1498,7 +1524,7 @@ function slimMigrationProject(row) {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
};
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) {
tasks = tasks.slice(0, limit);
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) {
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
}
})
};
}
function slimCutoverBatch(row) {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
};
});
}
function slimCollectBatch(row, taskId = "") {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
};
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -1624,7 +1650,7 @@ function slimEvalRun(row) {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
};
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) {
}
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) {
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
}
})
};
}
async function getBizMonitorContext(client, args, signal) {
@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
if (result.ok === false) {
throw new Error(JSON.stringify(result));
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
}
return result;
return safe;
}
});
}

View file

@ -737,6 +737,32 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args);
return toLosslessJson(await execute(args));
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -1481,7 +1507,7 @@ function taskBriefNe(brief) {
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -1498,7 +1524,7 @@ function slimMigrationProject(row) {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
};
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) {
tasks = tasks.slice(0, limit);
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) {
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
}
})
};
}
function slimCutoverBatch(row) {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
};
});
}
function slimCollectBatch(row, taskId = "") {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
};
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -1624,7 +1650,7 @@ function slimEvalRun(row) {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
};
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) {
}
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) {
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
}
})
};
}
async function getBizMonitorContext(client, args, signal) {
@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
if (result.ok === false) {
throw new Error(JSON.stringify(result));
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
}
return result;
return safe;
}
});
}

View file

@ -737,6 +737,32 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args);
return toLosslessJson(await execute(args));
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -1481,7 +1507,7 @@ function taskBriefNe(brief) {
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -1498,7 +1524,7 @@ function slimMigrationProject(row) {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
};
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) {
tasks = tasks.slice(0, limit);
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) {
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
}
})
};
}
function slimCutoverBatch(row) {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
};
});
}
function slimCollectBatch(row, taskId = "") {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
};
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -1624,7 +1650,7 @@ function slimEvalRun(row) {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
};
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) {
}
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) {
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
}
})
};
}
async function getBizMonitorContext(client, args, signal) {
@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
if (result.ok === false) {
throw new Error(JSON.stringify(result));
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
}
return result;
return safe;
}
});
}

View file

@ -737,6 +737,32 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
@ -766,7 +792,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args);
return toLosslessJson(await execute(args));
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -1481,7 +1507,7 @@ function taskBriefNe(brief) {
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -1498,7 +1524,7 @@ function slimMigrationProject(row) {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
};
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
@ -1567,7 +1593,7 @@ async function listBizMonitors(client, args, signal) {
tasks = tasks.slice(0, limit);
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -1578,11 +1604,11 @@ async function listBizMonitors(client, args, signal) {
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
}
})
};
}
function slimCutoverBatch(row) {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -1594,12 +1620,12 @@ function slimCutoverBatch(row) {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
};
});
}
function slimCollectBatch(row, taskId = "") {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -1609,12 +1635,12 @@ function slimCollectBatch(row, taskId = "") {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
};
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -1624,7 +1650,7 @@ function slimEvalRun(row) {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
};
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
@ -1733,7 +1759,7 @@ async function listBizMonitorBatches(client, args, signal) {
}
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -1747,7 +1773,7 @@ async function listBizMonitorBatches(client, args, signal) {
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
}
})
};
}
async function getBizMonitorContext(client, args, signal) {
@ -2491,10 +2517,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
if (result.ok === false) {
throw new Error(JSON.stringify(result));
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
}
return result;
return safe;
}
});
}

View file

@ -1599,6 +1599,32 @@ function resetKbContext() {
applyKbEnv(unconfiguredKbSnapshot());
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
@ -1628,7 +1654,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args);
return toLosslessJson(await execute(args));
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -3740,7 +3766,7 @@ function taskBriefNe(brief) {
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -3757,7 +3783,7 @@ function slimMigrationProject(row) {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
};
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
@ -3826,7 +3852,7 @@ async function listBizMonitors(client, args, signal) {
tasks = tasks.slice(0, limit);
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -3837,11 +3863,11 @@ async function listBizMonitors(client, args, signal) {
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
}
})
};
}
function slimCutoverBatch(row) {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -3853,12 +3879,12 @@ function slimCutoverBatch(row) {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
};
});
}
function slimCollectBatch(row, taskId = "") {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -3868,12 +3894,12 @@ function slimCollectBatch(row, taskId = "") {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
};
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -3883,7 +3909,7 @@ function slimEvalRun(row) {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
};
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
@ -3992,7 +4018,7 @@ async function listBizMonitorBatches(client, args, signal) {
}
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -4006,7 +4032,7 @@ async function listBizMonitorBatches(client, args, signal) {
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
}
})
};
}
async function getBizMonitorContext(client, args, signal) {
@ -4750,10 +4776,11 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
if (result.ok === false) {
throw new Error(JSON.stringify(result));
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
}
return result;
return safe;
}
});
}

View file

@ -3,6 +3,7 @@
*/
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
import { omitUndefined } from './json-safe.ts'
const EXEC_MAX_COMMANDS = 5
@ -343,7 +344,7 @@ function taskBriefNe(brief: unknown): string {
function slimMigrationProject(row: NetxJson): NetxJson {
const mt = asRecord(row.monitor_template)
return {
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
@ -360,7 +361,7 @@ function slimMigrationProject(row: NetxJson): NetxJson {
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at,
}
})
}
function matchesBizMonitorQ(row: NetxJson, q: string): boolean {
@ -440,7 +441,7 @@ export async function listBizMonitors(
return {
ok: true,
data: {
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
@ -451,12 +452,12 @@ export async function listBizMonitors(
tasks_returned: wantTasks ? tasks.length : 0,
},
next: 'Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id.',
},
}),
}
}
function slimCutoverBatch(row: NetxJson): NetxJson {
return {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
@ -468,13 +469,13 @@ function slimCutoverBatch(row: NetxJson): NetxJson {
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at,
}
})
}
function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson {
return {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
@ -484,13 +485,13 @@ function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson {
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at,
}
})
}
function slimEvalRun(row: NetxJson): NetxJson {
const summary = asRecord(row.summary)
const progress = asRecord(summary.progress)
return {
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
@ -500,7 +501,7 @@ function slimEvalRun(row: NetxJson): NetxJson {
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total,
}
})
}
/**
@ -646,7 +647,7 @@ export async function listBizMonitorBatches(
return {
ok: true,
data: {
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
@ -664,7 +665,7 @@ export async function listBizMonitorBatches(
: wantCollect
? 'Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw.'
: 'Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id).',
},
}),
}
}

41
src/netx/json-safe.ts Normal file
View file

@ -0,0 +1,41 @@
/**
* DSH tool outputs must be lossless JSON: no `undefined` property values,
* no NaN/Infinity, no class instances. Build plain objects carefully, or
* round-trip through JSON before returning from a tool.
*/
import type { NetxJson } from './http.ts'
/**
* Detach a plain JSON-compatible value for DSH tool results.
* Drops object keys whose value is `undefined` (via JSON.stringify).
* @param value - handler result (may contain undefined holes).
* @returns parsed plain JSON object, or a safe error envelope.
*/
export function toLosslessJson(value: unknown): NetxJson {
try {
const text = JSON.stringify(value)
if (text === undefined) {
return { ok: false, error: 'tool_result_not_json', detail: 'undefined_root' }
}
const parsed: unknown = JSON.parse(text)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as NetxJson
}
return { ok: true, data: { value: parsed } }
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return { ok: false, error: 'tool_result_not_json', detail: detail.slice(0, 400) }
}
}
/**
* Build a record omitting keys whose value is `undefined` (keeps null).
*/
export function omitUndefined(record: Record<string, unknown>): NetxJson {
const out: NetxJson = {}
for (const [key, value] of Object.entries(record)) {
if (value !== undefined) out[key] = value as NetxJson[string]
}
return out
}

View file

@ -17,6 +17,7 @@ import {
} from './kb-local-ops.ts'
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
import { getKbContext } from './kb-runtime.ts'
import { toLosslessJson } from './json-safe.ts'
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
@ -56,7 +57,7 @@ function tool(
isConcurrencySafe: () => false,
async execute(args) {
try {
return await execute(args as Record<string, unknown>)
return toLosslessJson(await execute(args as Record<string, unknown>))
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error))
}

View file

@ -11,6 +11,7 @@ import {
type NetxCapabilityGroups,
} from './capability-groups.ts'
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { toLosslessJson } from './json-safe.ts'
import { getNetxConnection } from './runtime.ts'
import * as H from './handlers.ts'
import * as T from './topology-handlers.ts'
@ -74,10 +75,13 @@ function tool(
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args as NetxJson, exec.signal)
if (result.ok === false) {
throw new Error(JSON.stringify(result))
// DSH rejects tool results that contain `undefined` property holes
// ("value is not lossless JSON"). Round-trip strips them.
const safe = toLosslessJson(result)
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe))
}
return result
return safe
},
})
}

View file

@ -39,6 +39,8 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas;
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizCollectCommandRaw'))
})

40
test/json-safe.test.mjs Normal file
View file

@ -0,0 +1,40 @@
/**
* Lossless JSON helpers for DSH tool results.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { omitUndefined, toLosslessJson } from '../src/netx/json-safe.ts'
test('omitUndefined drops undefined keys but keeps null/false/0', () => {
assert.deepEqual(
omitUndefined({ a: 1, b: undefined, c: null, d: false, e: 0 }),
{ a: 1, c: null, d: false, e: 0 },
)
})
test('toLosslessJson strips undefined holes (listBizMonitors-style payload)', () => {
const raw = {
ok: true,
data: {
kind: 'projects',
projects: [{ id: 'p1', name: 'demo' }],
tasks: undefined,
next: 'go',
},
}
const safe = toLosslessJson(raw)
assert.equal(safe.ok, true)
const data = safe.data
assert.ok(data && typeof data === 'object' && !Array.isArray(data))
assert.equal(Object.hasOwn(data, 'tasks'), false)
assert.deepEqual(data.projects, [{ id: 'p1', name: 'demo' }])
})
test('toLosslessJson keeps failure envelopes serializable', () => {
const safe = toLosslessJson({ ok: false, error: 'x', detail: undefined })
assert.equal(safe.ok, false)
assert.equal(safe.error, 'x')
assert.equal(Object.hasOwn(safe, 'detail'), false)
})