From 4dd628b7ccebb5c0c542dadb0b2813f60c30d3d3 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 15:35:08 +0800 Subject: [PATCH] Fix bizMonitor tool results failing DSH lossless JSON (strip undefined holes). Co-authored-by: Cursor --- lib/agent-tools-biz-monitor.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-common.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-nms.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-ops.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools-topology.js | 61 ++++++++++++++++++++++++--------- lib/agent-tools.js | 61 ++++++++++++++++++++++++--------- lib/index.js | 61 ++++++++++++++++++++++++--------- src/netx/handlers.ts | 27 ++++++++------- src/netx/json-safe.ts | 41 ++++++++++++++++++++++ src/netx/kb-local-tools.ts | 3 +- src/netx/tools.ts | 10 ++++-- test/capability-groups.test.mjs | 2 ++ test/json-safe.test.mjs | 40 +++++++++++++++++++++ 13 files changed, 414 insertions(+), 136 deletions(-) create mode 100644 src/netx/json-safe.ts create mode 100644 test/json-safe.test.mjs diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index b31986f..97b3358 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -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; } }); } diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index f711889..8af8c8f 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -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; } }); } diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 5d3c406..1f4a3b7 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -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; } }); } diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 3687d5a..90e9342 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -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; } }); } diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 5c01f5c..3baa967 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -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; } }); } diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 2ca382a..dca3139 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -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; } }); } diff --git a/lib/index.js b/lib/index.js index d11f6e7..e5ec056 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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; } }); } diff --git a/src/netx/handlers.ts b/src/netx/handlers.ts index 305e6cc..faa68df 100644 --- a/src/netx/handlers.ts +++ b/src/netx/handlers.ts @@ -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).', - }, + }), } } diff --git a/src/netx/json-safe.ts b/src/netx/json-safe.ts new file mode 100644 index 0000000..86d6476 --- /dev/null +++ b/src/netx/json-safe.ts @@ -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): NetxJson { + const out: NetxJson = {} + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) out[key] = value as NetxJson[string] + } + return out +} diff --git a/src/netx/kb-local-tools.ts b/src/netx/kb-local-tools.ts index 3dc08f1..d79d8d4 100644 --- a/src/netx/kb-local-tools.ts +++ b/src/netx/kb-local-tools.ts @@ -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) + return toLosslessJson(await execute(args as Record)) } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)) } diff --git a/src/netx/tools.ts b/src/netx/tools.ts index 70a4857..81378ce 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -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 }, }) } diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs index 8cf37c3..20c204c 100644 --- a/test/capability-groups.test.mjs +++ b/test/capability-groups.test.mjs @@ -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')) }) diff --git a/test/json-safe.test.mjs b/test/json-safe.test.mjs new file mode 100644 index 0000000..286bd7f --- /dev/null +++ b/test/json-safe.test.mjs @@ -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) +})