From 30d4df23fa4c533ed5fceafc2e12391e5d8c631d Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 6 Oct 2026 18:32:17 +0800 Subject: [PATCH] Sync dsh-netxops 0.1.41 with NetX async exec jobs and capability hints. Add netx__getNeExecJob, route execManagedNe async to /exec-jobs, and document getManagedNe.capability for agents. Co-authored-by: Cursor --- docs/TOOL_MAP.md | 2 +- lib/agent-tools-biz-monitor.js | 99 +++++++++++++++++-- lib/agent-tools-common.js | 99 +++++++++++++++++-- lib/agent-tools-nms.js | 99 +++++++++++++++++-- lib/agent-tools-ops.js | 99 +++++++++++++++++-- lib/agent-tools-topology.js | 99 +++++++++++++++++-- lib/agent-tools.js | 99 +++++++++++++++++-- lib/index.js | 99 +++++++++++++++++-- package.json | 2 +- .../netxops/skills/ops/netx-ops/reference.md | 2 + src/netx/capability-groups.ts | 1 + src/netx/handlers.ts | 81 ++++++++++++++- src/netx/tools.ts | 16 ++- test/capability-groups.test.mjs | 1 + 14 files changed, 740 insertions(+), 58 deletions(-) diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 0bcaa34..0b00ad4 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -18,7 +18,7 @@ Model names: `netx__`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` | Tool | Role | |------|------| | `queryNmsAlarms` … `sqlQueryNms` | Alarm + inventory + SQL | -| `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | Managed CLI (login / show) | +| `listManagedNe` / `getManagedNe` / `execManagedNe` / `getNeExecJob` / `listCliTargets` | Managed CLI (login / show / async jobs) | | `findTopologyPaths` | Fabric path lookup | ## bizMonitor → `netx-biz-monitor` diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index 97b3358..761642c 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -18,6 +18,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", + "netx__getNeExecJob", "netx__listCliTargets", "netx__findTopologyPaths" ]), @@ -1133,7 +1134,60 @@ function quoteNeId(neId) { } // src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; +var EXEC_MAX_COMMANDS_CAP = 50; +var EXEC_MAX_COMMANDS_DEFAULT = 5; +function execMaxCommands() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10); + const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT; + return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n)); +} +function asyncMinNes() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10); + const n = Number.isFinite(raw) ? raw : 4; + return Math.max(0, Math.min(50, n)); +} +function truthyAsyncFlag(raw) { + if (raw === undefined || raw === null) + return; + if (typeof raw === "boolean") + return raw; + const text = String(raw).trim().toLowerCase(); + if (!text) + return; + if (["1", "true", "yes", "on"].includes(text)) + return true; + if (["0", "false", "no", "off"].includes(text)) + return false; + return; +} +function countExecNeTargets(args) { + let n = 0; + for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { + const val = strList(args, key); + if (val.length > 0) + n = Math.max(n, val.length); + } + const targets = args.targets; + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length); + } + if (n === 0) { + if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) + return 1; + } + return n; +} +function shouldRunExecAsync(args) { + const flag = truthyAsyncFlag(args.async); + if (flag === false) + return false; + if (flag === true) + return true; + const minN = asyncMinNes(); + if (minN <= 0) + return false; + return countExecNeTargets(args) >= minN; +} var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", @@ -1375,6 +1429,8 @@ async function getManagedNe(client, args, signal) { return out; } async function execManagedNe(client, args, signal) { + const maxCmds = execMaxCommands(); + const wantAsync = shouldRunExecAsync(args); const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); @@ -1408,7 +1464,7 @@ async function execManagedNe(client, args, signal) { if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; @@ -1417,6 +1473,13 @@ async function execManagedNe(client, args, signal) { const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); + if (wantAsync) { + const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); + if (out3.ok !== true) + return out3; + const data3 = asRecord(out3.data); + return Object.keys(data3).length > 0 ? data3 : out3; + } const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; @@ -1439,7 +1502,7 @@ async function execManagedNe(client, args, signal) { if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { @@ -1450,6 +1513,13 @@ async function execManagedNe(client, args, signal) { body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; + if (wantAsync) { + const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + return Object.keys(data2).length > 0 ? data2 : out2; + } const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; @@ -1459,6 +1529,18 @@ async function execManagedNe(client, args, signal) { } return { ok: true, data }; } +async function getNeExecJob(client, args, signal) { + const jobId = str2(args, "job_id").trim(); + if (!jobId) { + return { + ok: false, + error: "job_id_required", + error_code: "job_id_required", + hint: "Pass job_id from execManagedNe async ack." + }; + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal); +} async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), @@ -2614,12 +2696,12 @@ function registerNetxTools(ctx, connection, options) { page: num4(), page_size: num4() }, listManagedNe, getClient, t), - tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), - tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), @@ -2628,7 +2710,7 @@ function registerNetxTools(ctx, connection, options) { ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", items: { type: "object", additionalProperties: false, @@ -2643,8 +2725,11 @@ function registerNetxTools(ctx, connection, options) { commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("oclaw-only async hint; ignored by native REST client.") + async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", { + job_id: str4("Job id from execManagedNe async response.") + }, getNeExecJob, getClient, Math.max(t, 60000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 8af8c8f..7d8eff6 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -18,6 +18,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", + "netx__getNeExecJob", "netx__listCliTargets", "netx__findTopologyPaths" ]), @@ -1133,7 +1134,60 @@ function quoteNeId(neId) { } // src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; +var EXEC_MAX_COMMANDS_CAP = 50; +var EXEC_MAX_COMMANDS_DEFAULT = 5; +function execMaxCommands() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10); + const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT; + return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n)); +} +function asyncMinNes() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10); + const n = Number.isFinite(raw) ? raw : 4; + return Math.max(0, Math.min(50, n)); +} +function truthyAsyncFlag(raw) { + if (raw === undefined || raw === null) + return; + if (typeof raw === "boolean") + return raw; + const text = String(raw).trim().toLowerCase(); + if (!text) + return; + if (["1", "true", "yes", "on"].includes(text)) + return true; + if (["0", "false", "no", "off"].includes(text)) + return false; + return; +} +function countExecNeTargets(args) { + let n = 0; + for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { + const val = strList(args, key); + if (val.length > 0) + n = Math.max(n, val.length); + } + const targets = args.targets; + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length); + } + if (n === 0) { + if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) + return 1; + } + return n; +} +function shouldRunExecAsync(args) { + const flag = truthyAsyncFlag(args.async); + if (flag === false) + return false; + if (flag === true) + return true; + const minN = asyncMinNes(); + if (minN <= 0) + return false; + return countExecNeTargets(args) >= minN; +} var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", @@ -1375,6 +1429,8 @@ async function getManagedNe(client, args, signal) { return out; } async function execManagedNe(client, args, signal) { + const maxCmds = execMaxCommands(); + const wantAsync = shouldRunExecAsync(args); const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); @@ -1408,7 +1464,7 @@ async function execManagedNe(client, args, signal) { if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; @@ -1417,6 +1473,13 @@ async function execManagedNe(client, args, signal) { const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); + if (wantAsync) { + const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); + if (out3.ok !== true) + return out3; + const data3 = asRecord(out3.data); + return Object.keys(data3).length > 0 ? data3 : out3; + } const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; @@ -1439,7 +1502,7 @@ async function execManagedNe(client, args, signal) { if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { @@ -1450,6 +1513,13 @@ async function execManagedNe(client, args, signal) { body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; + if (wantAsync) { + const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + return Object.keys(data2).length > 0 ? data2 : out2; + } const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; @@ -1459,6 +1529,18 @@ async function execManagedNe(client, args, signal) { } return { ok: true, data }; } +async function getNeExecJob(client, args, signal) { + const jobId = str2(args, "job_id").trim(); + if (!jobId) { + return { + ok: false, + error: "job_id_required", + error_code: "job_id_required", + hint: "Pass job_id from execManagedNe async ack." + }; + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal); +} async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), @@ -2614,12 +2696,12 @@ function registerNetxTools(ctx, connection, options) { page: num4(), page_size: num4() }, listManagedNe, getClient, t), - tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), - tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), @@ -2628,7 +2710,7 @@ function registerNetxTools(ctx, connection, options) { ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", items: { type: "object", additionalProperties: false, @@ -2643,8 +2725,11 @@ function registerNetxTools(ctx, connection, options) { commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("oclaw-only async hint; ignored by native REST client.") + async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", { + job_id: str4("Job id from execManagedNe async response.") + }, getNeExecJob, getClient, Math.max(t, 60000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 1f4a3b7..497e79f 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -18,6 +18,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", + "netx__getNeExecJob", "netx__listCliTargets", "netx__findTopologyPaths" ]), @@ -1133,7 +1134,60 @@ function quoteNeId(neId) { } // src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; +var EXEC_MAX_COMMANDS_CAP = 50; +var EXEC_MAX_COMMANDS_DEFAULT = 5; +function execMaxCommands() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10); + const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT; + return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n)); +} +function asyncMinNes() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10); + const n = Number.isFinite(raw) ? raw : 4; + return Math.max(0, Math.min(50, n)); +} +function truthyAsyncFlag(raw) { + if (raw === undefined || raw === null) + return; + if (typeof raw === "boolean") + return raw; + const text = String(raw).trim().toLowerCase(); + if (!text) + return; + if (["1", "true", "yes", "on"].includes(text)) + return true; + if (["0", "false", "no", "off"].includes(text)) + return false; + return; +} +function countExecNeTargets(args) { + let n = 0; + for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { + const val = strList(args, key); + if (val.length > 0) + n = Math.max(n, val.length); + } + const targets = args.targets; + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length); + } + if (n === 0) { + if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) + return 1; + } + return n; +} +function shouldRunExecAsync(args) { + const flag = truthyAsyncFlag(args.async); + if (flag === false) + return false; + if (flag === true) + return true; + const minN = asyncMinNes(); + if (minN <= 0) + return false; + return countExecNeTargets(args) >= minN; +} var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", @@ -1375,6 +1429,8 @@ async function getManagedNe(client, args, signal) { return out; } async function execManagedNe(client, args, signal) { + const maxCmds = execMaxCommands(); + const wantAsync = shouldRunExecAsync(args); const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); @@ -1408,7 +1464,7 @@ async function execManagedNe(client, args, signal) { if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; @@ -1417,6 +1473,13 @@ async function execManagedNe(client, args, signal) { const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); + if (wantAsync) { + const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); + if (out3.ok !== true) + return out3; + const data3 = asRecord(out3.data); + return Object.keys(data3).length > 0 ? data3 : out3; + } const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; @@ -1439,7 +1502,7 @@ async function execManagedNe(client, args, signal) { if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { @@ -1450,6 +1513,13 @@ async function execManagedNe(client, args, signal) { body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; + if (wantAsync) { + const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + return Object.keys(data2).length > 0 ? data2 : out2; + } const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; @@ -1459,6 +1529,18 @@ async function execManagedNe(client, args, signal) { } return { ok: true, data }; } +async function getNeExecJob(client, args, signal) { + const jobId = str2(args, "job_id").trim(); + if (!jobId) { + return { + ok: false, + error: "job_id_required", + error_code: "job_id_required", + hint: "Pass job_id from execManagedNe async ack." + }; + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal); +} async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), @@ -2614,12 +2696,12 @@ function registerNetxTools(ctx, connection, options) { page: num4(), page_size: num4() }, listManagedNe, getClient, t), - tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), - tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), @@ -2628,7 +2710,7 @@ function registerNetxTools(ctx, connection, options) { ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", items: { type: "object", additionalProperties: false, @@ -2643,8 +2725,11 @@ function registerNetxTools(ctx, connection, options) { commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("oclaw-only async hint; ignored by native REST client.") + async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", { + job_id: str4("Job id from execManagedNe async response.") + }, getNeExecJob, getClient, Math.max(t, 60000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 90e9342..93f1558 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -18,6 +18,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", + "netx__getNeExecJob", "netx__listCliTargets", "netx__findTopologyPaths" ]), @@ -1133,7 +1134,60 @@ function quoteNeId(neId) { } // src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; +var EXEC_MAX_COMMANDS_CAP = 50; +var EXEC_MAX_COMMANDS_DEFAULT = 5; +function execMaxCommands() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10); + const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT; + return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n)); +} +function asyncMinNes() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10); + const n = Number.isFinite(raw) ? raw : 4; + return Math.max(0, Math.min(50, n)); +} +function truthyAsyncFlag(raw) { + if (raw === undefined || raw === null) + return; + if (typeof raw === "boolean") + return raw; + const text = String(raw).trim().toLowerCase(); + if (!text) + return; + if (["1", "true", "yes", "on"].includes(text)) + return true; + if (["0", "false", "no", "off"].includes(text)) + return false; + return; +} +function countExecNeTargets(args) { + let n = 0; + for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { + const val = strList(args, key); + if (val.length > 0) + n = Math.max(n, val.length); + } + const targets = args.targets; + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length); + } + if (n === 0) { + if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) + return 1; + } + return n; +} +function shouldRunExecAsync(args) { + const flag = truthyAsyncFlag(args.async); + if (flag === false) + return false; + if (flag === true) + return true; + const minN = asyncMinNes(); + if (minN <= 0) + return false; + return countExecNeTargets(args) >= minN; +} var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", @@ -1375,6 +1429,8 @@ async function getManagedNe(client, args, signal) { return out; } async function execManagedNe(client, args, signal) { + const maxCmds = execMaxCommands(); + const wantAsync = shouldRunExecAsync(args); const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); @@ -1408,7 +1464,7 @@ async function execManagedNe(client, args, signal) { if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; @@ -1417,6 +1473,13 @@ async function execManagedNe(client, args, signal) { const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); + if (wantAsync) { + const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); + if (out3.ok !== true) + return out3; + const data3 = asRecord(out3.data); + return Object.keys(data3).length > 0 ? data3 : out3; + } const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; @@ -1439,7 +1502,7 @@ async function execManagedNe(client, args, signal) { if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { @@ -1450,6 +1513,13 @@ async function execManagedNe(client, args, signal) { body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; + if (wantAsync) { + const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + return Object.keys(data2).length > 0 ? data2 : out2; + } const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; @@ -1459,6 +1529,18 @@ async function execManagedNe(client, args, signal) { } return { ok: true, data }; } +async function getNeExecJob(client, args, signal) { + const jobId = str2(args, "job_id").trim(); + if (!jobId) { + return { + ok: false, + error: "job_id_required", + error_code: "job_id_required", + hint: "Pass job_id from execManagedNe async ack." + }; + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal); +} async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), @@ -2614,12 +2696,12 @@ function registerNetxTools(ctx, connection, options) { page: num4(), page_size: num4() }, listManagedNe, getClient, t), - tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), - tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), @@ -2628,7 +2710,7 @@ function registerNetxTools(ctx, connection, options) { ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", items: { type: "object", additionalProperties: false, @@ -2643,8 +2725,11 @@ function registerNetxTools(ctx, connection, options) { commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("oclaw-only async hint; ignored by native REST client.") + async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", { + job_id: str4("Job id from execManagedNe async response.") + }, getNeExecJob, getClient, Math.max(t, 60000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 3baa967..9f91257 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -18,6 +18,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", + "netx__getNeExecJob", "netx__listCliTargets", "netx__findTopologyPaths" ]), @@ -1133,7 +1134,60 @@ function quoteNeId(neId) { } // src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; +var EXEC_MAX_COMMANDS_CAP = 50; +var EXEC_MAX_COMMANDS_DEFAULT = 5; +function execMaxCommands() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10); + const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT; + return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n)); +} +function asyncMinNes() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10); + const n = Number.isFinite(raw) ? raw : 4; + return Math.max(0, Math.min(50, n)); +} +function truthyAsyncFlag(raw) { + if (raw === undefined || raw === null) + return; + if (typeof raw === "boolean") + return raw; + const text = String(raw).trim().toLowerCase(); + if (!text) + return; + if (["1", "true", "yes", "on"].includes(text)) + return true; + if (["0", "false", "no", "off"].includes(text)) + return false; + return; +} +function countExecNeTargets(args) { + let n = 0; + for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { + const val = strList(args, key); + if (val.length > 0) + n = Math.max(n, val.length); + } + const targets = args.targets; + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length); + } + if (n === 0) { + if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) + return 1; + } + return n; +} +function shouldRunExecAsync(args) { + const flag = truthyAsyncFlag(args.async); + if (flag === false) + return false; + if (flag === true) + return true; + const minN = asyncMinNes(); + if (minN <= 0) + return false; + return countExecNeTargets(args) >= minN; +} var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", @@ -1375,6 +1429,8 @@ async function getManagedNe(client, args, signal) { return out; } async function execManagedNe(client, args, signal) { + const maxCmds = execMaxCommands(); + const wantAsync = shouldRunExecAsync(args); const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); @@ -1408,7 +1464,7 @@ async function execManagedNe(client, args, signal) { if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; @@ -1417,6 +1473,13 @@ async function execManagedNe(client, args, signal) { const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); + if (wantAsync) { + const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); + if (out3.ok !== true) + return out3; + const data3 = asRecord(out3.data); + return Object.keys(data3).length > 0 ? data3 : out3; + } const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; @@ -1439,7 +1502,7 @@ async function execManagedNe(client, args, signal) { if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { @@ -1450,6 +1513,13 @@ async function execManagedNe(client, args, signal) { body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; + if (wantAsync) { + const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + return Object.keys(data2).length > 0 ? data2 : out2; + } const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; @@ -1459,6 +1529,18 @@ async function execManagedNe(client, args, signal) { } return { ok: true, data }; } +async function getNeExecJob(client, args, signal) { + const jobId = str2(args, "job_id").trim(); + if (!jobId) { + return { + ok: false, + error: "job_id_required", + error_code: "job_id_required", + hint: "Pass job_id from execManagedNe async ack." + }; + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal); +} async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), @@ -2614,12 +2696,12 @@ function registerNetxTools(ctx, connection, options) { page: num4(), page_size: num4() }, listManagedNe, getClient, t), - tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), - tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), @@ -2628,7 +2710,7 @@ function registerNetxTools(ctx, connection, options) { ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", items: { type: "object", additionalProperties: false, @@ -2643,8 +2725,11 @@ function registerNetxTools(ctx, connection, options) { commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("oclaw-only async hint; ignored by native REST client.") + async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", { + job_id: str4("Job id from execManagedNe async response.") + }, getNeExecJob, getClient, Math.max(t, 60000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), diff --git a/lib/agent-tools.js b/lib/agent-tools.js index dca3139..b5ad2cf 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -18,6 +18,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", + "netx__getNeExecJob", "netx__listCliTargets", "netx__findTopologyPaths" ]), @@ -1133,7 +1134,60 @@ function quoteNeId(neId) { } // src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; +var EXEC_MAX_COMMANDS_CAP = 50; +var EXEC_MAX_COMMANDS_DEFAULT = 5; +function execMaxCommands() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10); + const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT; + return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n)); +} +function asyncMinNes() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10); + const n = Number.isFinite(raw) ? raw : 4; + return Math.max(0, Math.min(50, n)); +} +function truthyAsyncFlag(raw) { + if (raw === undefined || raw === null) + return; + if (typeof raw === "boolean") + return raw; + const text = String(raw).trim().toLowerCase(); + if (!text) + return; + if (["1", "true", "yes", "on"].includes(text)) + return true; + if (["0", "false", "no", "off"].includes(text)) + return false; + return; +} +function countExecNeTargets(args) { + let n = 0; + for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { + const val = strList(args, key); + if (val.length > 0) + n = Math.max(n, val.length); + } + const targets = args.targets; + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length); + } + if (n === 0) { + if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) + return 1; + } + return n; +} +function shouldRunExecAsync(args) { + const flag = truthyAsyncFlag(args.async); + if (flag === false) + return false; + if (flag === true) + return true; + const minN = asyncMinNes(); + if (minN <= 0) + return false; + return countExecNeTargets(args) >= minN; +} var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", @@ -1375,6 +1429,8 @@ async function getManagedNe(client, args, signal) { return out; } async function execManagedNe(client, args, signal) { + const maxCmds = execMaxCommands(); + const wantAsync = shouldRunExecAsync(args); const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); @@ -1408,7 +1464,7 @@ async function execManagedNe(client, args, signal) { if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; @@ -1417,6 +1473,13 @@ async function execManagedNe(client, args, signal) { const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); + if (wantAsync) { + const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); + if (out3.ok !== true) + return out3; + const data3 = asRecord(out3.data); + return Object.keys(data3).length > 0 ? data3 : out3; + } const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; @@ -1439,7 +1502,7 @@ async function execManagedNe(client, args, signal) { if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { @@ -1450,6 +1513,13 @@ async function execManagedNe(client, args, signal) { body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; + if (wantAsync) { + const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + return Object.keys(data2).length > 0 ? data2 : out2; + } const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; @@ -1459,6 +1529,18 @@ async function execManagedNe(client, args, signal) { } return { ok: true, data }; } +async function getNeExecJob(client, args, signal) { + const jobId = str2(args, "job_id").trim(); + if (!jobId) { + return { + ok: false, + error: "job_id_required", + error_code: "job_id_required", + hint: "Pass job_id from execManagedNe async ack." + }; + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal); +} async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), @@ -2614,12 +2696,12 @@ function registerNetxTools(ctx, connection, options) { page: num4(), page_size: num4() }, listManagedNe, getClient, t), - tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), - tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), @@ -2628,7 +2710,7 @@ function registerNetxTools(ctx, connection, options) { ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", items: { type: "object", additionalProperties: false, @@ -2643,8 +2725,11 @@ function registerNetxTools(ctx, connection, options) { commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("oclaw-only async hint; ignored by native REST client.") + async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", { + job_id: str4("Job id from execManagedNe async response.") + }, getNeExecJob, getClient, Math.max(t, 60000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), diff --git a/lib/index.js b/lib/index.js index e5ec056..9aa0732 100644 --- a/lib/index.js +++ b/lib/index.js @@ -589,6 +589,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", + "netx__getNeExecJob", "netx__listCliTargets", "netx__findTopologyPaths" ]), @@ -3392,7 +3393,60 @@ function quoteNeId(neId) { } // src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; +var EXEC_MAX_COMMANDS_CAP = 50; +var EXEC_MAX_COMMANDS_DEFAULT = 5; +function execMaxCommands() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ""), 10); + const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT; + return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n)); +} +function asyncMinNes() { + const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ""), 10); + const n = Number.isFinite(raw) ? raw : 4; + return Math.max(0, Math.min(50, n)); +} +function truthyAsyncFlag(raw) { + if (raw === undefined || raw === null) + return; + if (typeof raw === "boolean") + return raw; + const text = String(raw).trim().toLowerCase(); + if (!text) + return; + if (["1", "true", "yes", "on"].includes(text)) + return true; + if (["0", "false", "no", "off"].includes(text)) + return false; + return; +} +function countExecNeTargets(args) { + let n = 0; + for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { + const val = strList(args, key); + if (val.length > 0) + n = Math.max(n, val.length); + } + const targets = args.targets; + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter((t) => typeof t === "object" && t !== null && !Array.isArray(t)).length); + } + if (n === 0) { + if (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) + return 1; + } + return n; +} +function shouldRunExecAsync(args) { + const flag = truthyAsyncFlag(args.async); + if (flag === false) + return false; + if (flag === true) + return true; + const minN = asyncMinNes(); + if (minN <= 0) + return false; + return countExecNeTargets(args) >= minN; +} var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", @@ -3634,6 +3688,8 @@ async function getManagedNe(client, args, signal) { return out; } async function execManagedNe(client, args, signal) { + const maxCmds = execMaxCommands(); + const wantAsync = shouldRunExecAsync(args); const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); @@ -3667,7 +3723,7 @@ async function execManagedNe(client, args, signal) { if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; @@ -3676,6 +3732,13 @@ async function execManagedNe(client, args, signal) { const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); + if (wantAsync) { + const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); + if (out3.ok !== true) + return out3; + const data3 = asRecord(out3.data); + return Object.keys(data3).length > 0 ? data3 : out3; + } const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; @@ -3698,7 +3761,7 @@ async function execManagedNe(client, args, signal) { if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { @@ -3709,6 +3772,13 @@ async function execManagedNe(client, args, signal) { body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; + if (wantAsync) { + const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + return Object.keys(data2).length > 0 ? data2 : out2; + } const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; @@ -3718,6 +3788,18 @@ async function execManagedNe(client, args, signal) { } return { ok: true, data }; } +async function getNeExecJob(client, args, signal) { + const jobId = str2(args, "job_id").trim(); + if (!jobId) { + return { + ok: false, + error: "job_id_required", + error_code: "job_id_required", + hint: "Pass job_id from execManagedNe async ack." + }; + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal); +} async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), @@ -4873,12 +4955,12 @@ function registerNetxTools(ctx, connection, options) { page: num4(), page_size: num4() }, listManagedNe, getClient, t), - tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), - tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + tool2("netx__execManagedNe", "Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), @@ -4887,7 +4969,7 @@ function registerNetxTools(ctx, connection, options) { ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", items: { type: "object", additionalProperties: false, @@ -4902,8 +4984,11 @@ function registerNetxTools(ctx, connection, options) { commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("oclaw-only async hint; ignored by native REST client.") + async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") }, execManagedNe, getClient, Math.max(t, 300000)), + tool2("netx__getNeExecJob", "Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.", { + job_id: str4("Job id from execManagedNe async response.") + }, getNeExecJob, getClient, Math.max(t, 60000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), diff --git a/package.json b/package.json index 7bbc103..dc33283 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.40", + "version": "0.1.41", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/presets/netxops/skills/ops/netx-ops/reference.md b/presets/netxops/skills/ops/netx-ops/reference.md index 09eb7b9..cf7afcc 100644 --- a/presets/netxops/skills/ops/netx-ops/reference.md +++ b/presets/netxops/skills/ops/netx-ops/reference.md @@ -29,6 +29,8 @@ | 证据 | `queryNmsAlarmsRaw(field_preset=evidence)` | | 清单 | `queryNmsNeInventory` / `getNmsNe` | | 多台 CLI | `execManagedNe(nms_ne_ids=…)` 或 `targets` | +| 长任务 / ≥4 台 | `execManagedNe(async=true)` → `getNeExecJob(job_id=…)` | +| 设备能力 | `getManagedNe` → 读 `capability.recommended_mode` | | 路径 | `findTopologyPaths` | | A<>B 光 | 两端 host → 路径 → 多台一批 optic CLI | diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index a9e0ab4..2803621 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -59,6 +59,7 @@ export const TOOLS_BY_GROUP: Readonly 0) n = Math.max(n, val.length) + } + const targets = args.targets + if (Array.isArray(targets)) { + n = Math.max(n, targets.filter(t => typeof t === 'object' && t !== null && !Array.isArray(t)).length) + } + if (n === 0) { + if (str(args, 'ne_id').trim() || nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')) return 1 + } + return n +} + +function shouldRunExecAsync(args: NetxJson): boolean { + const flag = truthyAsyncFlag(args.async) + if (flag === false) return false + if (flag === true) return true + const minN = asyncMinNes() + if (minN <= 0) return false + return countExecNeTargets(args) >= minN +} const UME_RAW_FIELD_PRESETS: Record = { brief: [ @@ -218,6 +266,8 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: } export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const maxCmds = execMaxCommands() + const wantAsync = shouldRunExecAsync(args) const targetsRaw = args.targets const neIds = strList(args, 'ne_ids') const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', 'ume_ne_ids') @@ -249,7 +299,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: if (neIds.length > 0) body.ne_ids = neIds if (umeNeIds.length > 0) body.ume_ne_ids = umeNeIds if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' } } body.commands = sharedCommands @@ -257,6 +307,12 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: body.read_timeout_sec = clampInt(num(args, 'read_timeout_sec'), 60, 10, 120) const concurrency = num(args, 'concurrency') if (concurrency !== undefined) body.concurrency = clampInt(concurrency, 4, 1, 8) + if (wantAsync) { + const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000) + if (out.ok !== true) return out + const data = asRecord(out.data) + return Object.keys(data).length > 0 ? data : out + } const out = await client.post('/v1/managed-ne/exec-batch', body, signal, 600_000) if (out.ok !== true) return out const data = asRecord(out.data) @@ -279,7 +335,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: if (sharedCommands.length === 0) { return { ok: false, error: 'commands_required', error_code: 'commands_required' } } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { + if (sharedCommands.length > maxCmds) { return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' } } const body: NetxJson = { @@ -288,6 +344,12 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: } if (neId) body.ne_id = neId if (umeNeId) body.ume_ne_id = umeNeId + if (wantAsync) { + const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000) + if (out.ok !== true) return out + const data = asRecord(out.data) + return Object.keys(data).length > 0 ? data : out + } const out = await client.post('/v1/managed-ne/exec', body, signal, 300_000) if (out.ok !== true) return out const data = asRecord(out.data) @@ -297,6 +359,19 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: return { ok: true, data } } +export async function getNeExecJob(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const jobId = str(args, 'job_id').trim() + if (!jobId) { + return { + ok: false, + error: 'job_id_required', + error_code: 'job_id_required', + hint: 'Pass job_id from execManagedNe async ack.', + } + } + return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal) +} + export async function listCliTargets(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { const params: Record = { page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER), diff --git a/src/netx/tools.ts b/src/netx/tools.ts index 81378ce..a57d5b4 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -253,7 +253,7 @@ export function registerNetxTools( ), tool( 'netx__getManagedNe', - 'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.', + 'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.', { ne_id: str('Managed NE id'), managed_ne_id: str('Alias for ne_id'), @@ -263,7 +263,7 @@ export function registerNetxTools( ), tool( 'netx__execManagedNe', - 'Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.', + 'Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.', { ne_id: str(), nms_ne_id: str('NMS inventory id; alias ume_ne_id'), @@ -273,7 +273,7 @@ export function registerNetxTools( ume_ne_ids: strArr('Legacy alias of nms_ne_ids'), targets: { type: 'array' as const, - description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].', + description: 'Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].', items: { type: 'object' as const, additionalProperties: false, @@ -288,10 +288,18 @@ export function registerNetxTools( commands: strArr('Commands for single NE, or shared commands for batch.'), read_timeout_sec: num('Per-command read timeout (default 60; use 90–120 for slow show).'), concurrency: num('Parallel NEs for batch mode (1–8, default 4).'), - async: bool('oclaw-only async hint; ignored by native REST client.'), + async: bool('true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).'), }, H.execManagedNe, getClient, Math.max(t, 300_000), ), + tool( + 'netx__getNeExecJob', + 'Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.', + { + job_id: str('Job id from execManagedNe async response.'), + }, + H.getNeExecJob, getClient, Math.max(t, 60_000), + ), tool( 'netx__listCliTargets', 'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.', diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs index 20c204c..fa10ad7 100644 --- a/test/capability-groups.test.mjs +++ b/test/capability-groups.test.mjs @@ -33,6 +33,7 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; }) assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths')) assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe')) + assert.ok(TOOLS_BY_GROUP.ops.includes('netx__getNeExecJob')) assert.ok(TOOLS_BY_GROUP.ops.includes('netx__queryNmsAlarms')) assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree')) assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))