mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 04:00:46 +08:00
Scope netx__* tools to the Netx Ops preset only.
Register tools from the preset standing mount so standard and other agents no longer see them without Ops skills. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ed219842d3
commit
0f30d92302
11 changed files with 180 additions and 697 deletions
|
|
@ -25,7 +25,7 @@ Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details:
|
||||||
|
|
||||||
| In the plugin | Outside (data / runtime) |
|
| In the plugin | Outside (data / runtime) |
|
||||||
|---------------|---------------------------|
|
|---------------|---------------------------|
|
||||||
| `netx__*` REST tools + settings + credentials | netx HTTP API (URL + token) |
|
| `netx__*` REST tools (Ops preset only) + settings + credentials | netx HTTP API (URL + token) |
|
||||||
| Persona + UME / managed-NE skills | |
|
| Persona + UME / managed-NE skills | |
|
||||||
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# Host-plane Netx Ops bridge: settings + credentials → netx__* REST tools.
|
# Host-plane Netx Ops bridge: settings + credentials → connection publish.
|
||||||
|
# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`).
|
||||||
#
|
#
|
||||||
# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang
|
# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang
|
||||||
# (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN.
|
# (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ One install wires **all of**:
|
||||||
|
|
||||||
| Piece | How you use it |
|
| Piece | How you use it |
|
||||||
|-------|----------------|
|
|-------|----------------|
|
||||||
| Host tools | native `netx__*` tools → netx REST (Bearer token) |
|
| Host tools | Ops-preset-scoped `netx__*` → netx REST (Bearer token); other presets do not see them |
|
||||||
| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token) |
|
| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token) |
|
||||||
| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot) |
|
| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Netx Ops tool map
|
# Netx Ops tool map
|
||||||
|
|
||||||
Tools are registered by the host plugin (`dsh-netxops`) via `@deepseek-ai/dsh-tools`.
|
Tools are registered by the Ops agent preset (`dsh-netxops/tools`) into that preset's tool scope — not the host global layer — so other agent presets do not see them.
|
||||||
|
|
||||||
Model-facing name: `netx__<tool>`.
|
Model-facing name: `netx__<tool>`.
|
||||||
|
|
||||||
|
|
|
||||||
667
lib/index.js
667
lib/index.js
|
|
@ -7,637 +7,26 @@ import z from "@deepseek-ai/schemastery";
|
||||||
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
||||||
import * as DshSettings from "@deepseek-ai/dsh-settings";
|
import * as DshSettings from "@deepseek-ai/dsh-settings";
|
||||||
|
|
||||||
// src/netx/tools.ts
|
// src/netx/runtime.ts
|
||||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
||||||
|
function store() {
|
||||||
// src/netx/http.ts
|
const root = globalThis;
|
||||||
var PROTOCOL_KEY_ZH_TO_EN = {
|
let current = root[STORE_KEY];
|
||||||
其他: "Other",
|
if (current === undefined) {
|
||||||
时钟: "Clock",
|
current = { connection: undefined, listeners: new Set };
|
||||||
"OTN/光": "OTN/Optical",
|
root[STORE_KEY] = current;
|
||||||
电源: "Power"
|
|
||||||
};
|
|
||||||
function localizePayload(lang, data) {
|
|
||||||
if (!lang.trim().toLowerCase().startsWith("en"))
|
|
||||||
return data;
|
|
||||||
const proto = data.protocol_summary;
|
|
||||||
if (!Array.isArray(proto))
|
|
||||||
return data;
|
|
||||||
for (const row of proto) {
|
|
||||||
if (typeof row !== "object" || row === null || Array.isArray(row))
|
|
||||||
continue;
|
|
||||||
const rec = row;
|
|
||||||
const key = typeof rec.key === "string" ? rec.key : "";
|
|
||||||
const mapped = PROTOCOL_KEY_ZH_TO_EN[key];
|
|
||||||
if (mapped !== undefined)
|
|
||||||
rec.key = mapped;
|
|
||||||
}
|
}
|
||||||
return data;
|
return current;
|
||||||
}
|
}
|
||||||
function encodeQuery(params) {
|
function publishNetxConnection(next) {
|
||||||
const sp = new URLSearchParams;
|
const state = store();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
state.connection = next;
|
||||||
sp.set(key, String(value));
|
for (const listener of state.listeners)
|
||||||
}
|
listener();
|
||||||
const q = sp.toString();
|
|
||||||
return q.length > 0 ? `?${q}` : "";
|
|
||||||
}
|
|
||||||
function createNetxClient(connection) {
|
|
||||||
const base = connection.apiUrl.replace(/\/$/, "");
|
|
||||||
const headers = {
|
|
||||||
accept: "application/json"
|
|
||||||
};
|
|
||||||
if (connection.token.trim().length > 0) {
|
|
||||||
headers.authorization = `Bearer ${connection.token.trim()}`;
|
|
||||||
}
|
|
||||||
const langParams = () => {
|
|
||||||
const lang = connection.lang.trim().toLowerCase();
|
|
||||||
if (lang.startsWith("en"))
|
|
||||||
return { lang: "en" };
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
async function request(method, path, options = {}) {
|
|
||||||
const merged = { ...langParams(), ...options.params };
|
|
||||||
const url = `${base}${path}${encodeQuery(merged)}`;
|
|
||||||
const timeoutMs = options.timeoutMs ?? connection.timeoutMs;
|
|
||||||
const controller = new AbortController;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
}, timeoutMs);
|
|
||||||
const onOuterAbort = () => {
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
options.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
||||||
try {
|
|
||||||
const init = {
|
|
||||||
method,
|
|
||||||
headers: options.body === undefined ? headers : { ...headers, "content-type": "application/json" },
|
|
||||||
signal: controller.signal
|
|
||||||
};
|
|
||||||
if (options.body !== undefined)
|
|
||||||
init.body = JSON.stringify(options.body);
|
|
||||||
const resp = await fetch(url, init);
|
|
||||||
const text = await resp.text();
|
|
||||||
if (!resp.ok) {
|
|
||||||
return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) };
|
|
||||||
}
|
|
||||||
const data = text.length > 0 ? JSON.parse(text) : {};
|
|
||||||
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
|
|
||||||
return { ok: true, data: localizePayload(connection.lang, data) };
|
|
||||||
}
|
|
||||||
return { ok: true, data: { raw: data } };
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
options.signal?.removeEventListener("abort", onOuterAbort);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
get(path, params, signal, timeoutMs) {
|
|
||||||
return request("GET", path, { params, signal, timeoutMs });
|
|
||||||
},
|
|
||||||
post(path, body, signal, timeoutMs) {
|
|
||||||
return request("POST", path, { body, signal, timeoutMs });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function quoteNeId(neId) {
|
|
||||||
return encodeURIComponent(neId.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/netx/handlers.ts
|
|
||||||
var EXEC_MAX_COMMANDS = 5;
|
|
||||||
var UME_RAW_FIELD_PRESETS = {
|
|
||||||
brief: [
|
|
||||||
"alarm_alarm_key",
|
|
||||||
"alarm_host_name",
|
|
||||||
"alarm_perceived_severity",
|
|
||||||
"alarm_event_type",
|
|
||||||
"alarm_last_seen_at",
|
|
||||||
"ne_host_name",
|
|
||||||
"ne_user_label",
|
|
||||||
"ne_ne_name",
|
|
||||||
"ne_ip_address",
|
|
||||||
"ne_exists"
|
|
||||||
],
|
|
||||||
evidence: [
|
|
||||||
"alarm_alarm_key",
|
|
||||||
"alarm_host_name",
|
|
||||||
"alarm_object_name",
|
|
||||||
"alarm_event_type",
|
|
||||||
"alarm_native_probable_cause",
|
|
||||||
"alarm_perceived_severity",
|
|
||||||
"alarm_is_cleared",
|
|
||||||
"alarm_time_created",
|
|
||||||
"alarm_last_seen_at",
|
|
||||||
"ne_host_name",
|
|
||||||
"ne_user_label",
|
|
||||||
"ne_ne_name",
|
|
||||||
"ne_ip_address",
|
|
||||||
"ne_connection_status",
|
|
||||||
"ne_exists"
|
|
||||||
],
|
|
||||||
ne_debug: [
|
|
||||||
"alarm_alarm_key",
|
|
||||||
"alarm_ne_id",
|
|
||||||
"alarm_perceived_severity",
|
|
||||||
"alarm_last_seen_at",
|
|
||||||
"ne_user_label",
|
|
||||||
"ne_ne_name",
|
|
||||||
"ne_ip_address",
|
|
||||||
"ne_ipv6_address",
|
|
||||||
"ne_device_level",
|
|
||||||
"ne_host_name",
|
|
||||||
"ne_connection_status",
|
|
||||||
"ne_admin_status",
|
|
||||||
"ne_address_type",
|
|
||||||
"ne_maintain_status",
|
|
||||||
"ne_exists"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
function asRecord(value) {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
||||||
}
|
|
||||||
function str(args, key, fallback = "") {
|
|
||||||
const v = args[key];
|
|
||||||
if (typeof v === "string")
|
|
||||||
return v;
|
|
||||||
if (typeof v === "number" || typeof v === "boolean")
|
|
||||||
return String(v);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
function num(args, key) {
|
|
||||||
const v = args[key];
|
|
||||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
||||||
}
|
|
||||||
function bool(args, key) {
|
|
||||||
const v = args[key];
|
|
||||||
return typeof v === "boolean" ? v : undefined;
|
|
||||||
}
|
|
||||||
function strList(args, key) {
|
|
||||||
const v = args[key];
|
|
||||||
if (!Array.isArray(v))
|
|
||||||
return [];
|
|
||||||
return v.map((x) => String(x).trim()).filter((x) => x.length > 0);
|
|
||||||
}
|
|
||||||
function clampInt(value, fallback, min, max) {
|
|
||||||
const n = value === undefined ? fallback : Math.trunc(value);
|
|
||||||
return Math.max(min, Math.min(max, n));
|
|
||||||
}
|
|
||||||
function putStr(params, args, keys) {
|
|
||||||
for (const key of keys) {
|
|
||||||
const v = str(args, key).trim();
|
|
||||||
if (v)
|
|
||||||
params[key] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function queryUmeAlarms(client, args, signal) {
|
|
||||||
let page = clampInt(num(args, "page"), 1, 1, 2);
|
|
||||||
const pageSize = clampInt(num(args, "page_size"), 50, 1, 500);
|
|
||||||
const params = { page, page_size: pageSize };
|
|
||||||
putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]);
|
|
||||||
const keyword = str(args, "keyword").trim();
|
|
||||||
const neName = str(args, "ne_name").trim();
|
|
||||||
if (keyword)
|
|
||||||
params.keyword = keyword;
|
|
||||||
else if (neName)
|
|
||||||
params.keyword = neName;
|
|
||||||
return client.get("/v1/ume/alarms", params, signal);
|
|
||||||
}
|
|
||||||
async function aggregateUmeAlarmsRaw(client, args, signal) {
|
|
||||||
const params = {};
|
|
||||||
putStr(params, args, [
|
|
||||||
"group_by",
|
|
||||||
"group_by2",
|
|
||||||
"severity",
|
|
||||||
"is_cleared",
|
|
||||||
"ne_id",
|
|
||||||
"event_type",
|
|
||||||
"keyword",
|
|
||||||
"time_from",
|
|
||||||
"time_to",
|
|
||||||
"limit"
|
|
||||||
]);
|
|
||||||
if ("exclude_missing_host" in args) {
|
|
||||||
const flag = bool(args, "exclude_missing_host");
|
|
||||||
if (flag !== undefined)
|
|
||||||
params.exclude_missing_host = flag;
|
|
||||||
}
|
|
||||||
return client.get("/v1/ume/alarms/aggregate/raw", params, signal);
|
|
||||||
}
|
|
||||||
async function aggregateUmeAlarms(client, args, signal) {
|
|
||||||
if (str(args, "group_by").trim())
|
|
||||||
return aggregateUmeAlarmsRaw(client, args, signal);
|
|
||||||
const topNe = clampInt(num(args, "top_ne"), 50, 0, 500);
|
|
||||||
const params = { top_ne: topNe };
|
|
||||||
if ("exclude_missing_host" in args) {
|
|
||||||
const flag = bool(args, "exclude_missing_host");
|
|
||||||
if (flag !== undefined)
|
|
||||||
params.exclude_missing_host = flag;
|
|
||||||
}
|
|
||||||
putStr(params, args, ["severity", "time_from", "time_to"]);
|
|
||||||
return client.get("/v1/ume/alarms/aggregate", params, signal);
|
|
||||||
}
|
|
||||||
async function runUmeDiagnostics(client, _args, signal) {
|
|
||||||
return client.get("/v1/ume/diagnostics", undefined, signal);
|
|
||||||
}
|
|
||||||
async function queryUmeNeInventory(client, args, signal) {
|
|
||||||
const params = {
|
|
||||||
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
|
||||||
page_size: clampInt(num(args, "page_size"), 50, 1, 500)
|
|
||||||
};
|
|
||||||
putStr(params, args, ["keyword"]);
|
|
||||||
return client.get("/v1/ume/inventory/ne", params, signal);
|
|
||||||
}
|
|
||||||
async function getUmeNe(client, args, signal) {
|
|
||||||
const neId = str(args, "ne_id").trim();
|
|
||||||
if (!neId)
|
|
||||||
return { ok: false, error: "ne_id_required", error_code: "ne_id_required" };
|
|
||||||
return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal);
|
|
||||||
}
|
|
||||||
async function queryUmeAlarmsRaw(client, args, signal) {
|
|
||||||
const params = {
|
|
||||||
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
|
||||||
page_size: clampInt(num(args, "page_size"), 50, 1, 500)
|
|
||||||
};
|
|
||||||
putStr(params, args, [
|
|
||||||
"severity",
|
|
||||||
"is_cleared",
|
|
||||||
"ne_id",
|
|
||||||
"event_type",
|
|
||||||
"keyword",
|
|
||||||
"time_from",
|
|
||||||
"time_to",
|
|
||||||
"order_by",
|
|
||||||
"order"
|
|
||||||
]);
|
|
||||||
let fields = strList(args, "select_fields");
|
|
||||||
if (fields.length === 0) {
|
|
||||||
const preset = str(args, "field_preset").trim().toLowerCase();
|
|
||||||
fields = UME_RAW_FIELD_PRESETS[preset] ?? [];
|
|
||||||
}
|
|
||||||
if (fields.length > 0)
|
|
||||||
params.select_fields = fields.join(",");
|
|
||||||
return client.get("/v1/ume/alarms/raw", params, signal);
|
|
||||||
}
|
|
||||||
async function listUmeAlarmFields(client, _args, signal) {
|
|
||||||
return client.get("/v1/ume/alarms/fields", undefined, signal);
|
|
||||||
}
|
|
||||||
async function sqlQueryUme(client, args, signal) {
|
|
||||||
const sql = str(args, "sql").trim();
|
|
||||||
if (!sql)
|
|
||||||
return { ok: false, error: "sql_required" };
|
|
||||||
const limit = clampInt(num(args, "limit"), 200, 1, 2000);
|
|
||||||
const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000);
|
|
||||||
return client.post("/v1/sql/ume_query", {
|
|
||||||
sql,
|
|
||||||
limit,
|
|
||||||
statement_timeout_ms: statementTimeoutMs
|
|
||||||
}, signal, 60000);
|
|
||||||
}
|
|
||||||
async function listManagedNe(client, args, signal) {
|
|
||||||
const keyword = str(args, "keyword").trim();
|
|
||||||
const vendor = str(args, "vendor").trim();
|
|
||||||
const connectStatus = str(args, "connect_status").trim();
|
|
||||||
if (!(keyword || vendor || connectStatus)) {
|
|
||||||
return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" };
|
|
||||||
}
|
|
||||||
if (keyword && keyword.length < 2) {
|
|
||||||
return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" };
|
|
||||||
}
|
|
||||||
const params = {
|
|
||||||
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
|
||||||
page_size: clampInt(num(args, "page_size"), 20, 1, 100)
|
|
||||||
};
|
|
||||||
if (keyword)
|
|
||||||
params.keyword = keyword;
|
|
||||||
if (vendor)
|
|
||||||
params.vendor = vendor;
|
|
||||||
if (connectStatus)
|
|
||||||
params.connect_status = connectStatus;
|
|
||||||
return client.get("/v1/managed-ne", params, signal);
|
|
||||||
}
|
|
||||||
async function getManagedNe(client, args, signal) {
|
|
||||||
const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim();
|
|
||||||
if (!neId) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "ne_id_required",
|
|
||||||
error_code: "ne_id_required",
|
|
||||||
hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.",
|
|
||||||
example: { ne_id: "<managed-ne-uuid-from-listManagedNe>" }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal);
|
|
||||||
if (out.ok === false) {
|
|
||||||
const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase();
|
|
||||||
if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") {
|
|
||||||
return {
|
|
||||||
...out,
|
|
||||||
hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
async function execManagedNe(client, args, signal) {
|
|
||||||
const targetsRaw = args.targets;
|
|
||||||
const neIds = strList(args, "ne_ids");
|
|
||||||
const umeNeIds = strList(args, "ume_ne_ids");
|
|
||||||
const sharedCommands = strList(args, "commands");
|
|
||||||
const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0;
|
|
||||||
if (multi) {
|
|
||||||
const body2 = {};
|
|
||||||
if (Array.isArray(targetsRaw) && targetsRaw.length > 0) {
|
|
||||||
const cleaned = [];
|
|
||||||
for (const t of targetsRaw) {
|
|
||||||
if (typeof t !== "object" || t === null || Array.isArray(t))
|
|
||||||
continue;
|
|
||||||
const row = t;
|
|
||||||
const item = {};
|
|
||||||
const neId2 = str(row, "ne_id").trim();
|
|
||||||
const umeNeId2 = str(row, "ume_ne_id").trim();
|
|
||||||
if (neId2)
|
|
||||||
item.ne_id = neId2;
|
|
||||||
if (umeNeId2)
|
|
||||||
item.ume_ne_id = umeNeId2;
|
|
||||||
const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : [];
|
|
||||||
if (cmds.length > 0)
|
|
||||||
item.commands = cmds;
|
|
||||||
if (Object.keys(item).length > 0)
|
|
||||||
cleaned.push(item);
|
|
||||||
}
|
|
||||||
body2.targets = cleaned;
|
|
||||||
}
|
|
||||||
if (neIds.length > 0)
|
|
||||||
body2.ne_ids = neIds;
|
|
||||||
if (umeNeIds.length > 0)
|
|
||||||
body2.ume_ne_ids = umeNeIds;
|
|
||||||
if (sharedCommands.length > 0) {
|
|
||||||
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
|
||||||
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
|
|
||||||
}
|
|
||||||
body2.commands = sharedCommands;
|
|
||||||
}
|
|
||||||
body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120);
|
|
||||||
const concurrency = num(args, "concurrency");
|
|
||||||
if (concurrency !== undefined)
|
|
||||||
body2.concurrency = clampInt(concurrency, 4, 1, 8);
|
|
||||||
const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000);
|
|
||||||
if (out2.ok !== true)
|
|
||||||
return out2;
|
|
||||||
const data2 = asRecord(out2.data);
|
|
||||||
if (data2.ok === false) {
|
|
||||||
return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") };
|
|
||||||
}
|
|
||||||
return { ok: true, data: data2 };
|
|
||||||
}
|
|
||||||
const neId = str(args, "ne_id").trim();
|
|
||||||
const umeNeId = str(args, "ume_ne_id").trim();
|
|
||||||
if (Boolean(neId) === Boolean(umeNeId)) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "exactly_one_of_ne_id_or_ume_ne_id_required",
|
|
||||||
error_code: "exactly_one_of_ne_id_or_ume_ne_id_required",
|
|
||||||
hint: "For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (sharedCommands.length === 0) {
|
|
||||||
return { ok: false, error: "commands_required", error_code: "commands_required" };
|
|
||||||
}
|
|
||||||
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
|
||||||
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
|
|
||||||
}
|
|
||||||
const body = {
|
|
||||||
commands: sharedCommands,
|
|
||||||
read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120)
|
|
||||||
};
|
|
||||||
if (neId)
|
|
||||||
body.ne_id = neId;
|
|
||||||
if (umeNeId)
|
|
||||||
body.ume_ne_id = umeNeId;
|
|
||||||
const out = await client.post("/v1/managed-ne/exec", body, signal, 300000);
|
|
||||||
if (out.ok !== true)
|
|
||||||
return out;
|
|
||||||
const data = asRecord(out.data);
|
|
||||||
if (data.ok === false) {
|
|
||||||
return { ok: false, data, error: str(data, "error", "exec_failed") };
|
|
||||||
}
|
|
||||||
return { ok: true, data };
|
|
||||||
}
|
|
||||||
async function listCliTargets(client, args, signal) {
|
|
||||||
const params = {
|
|
||||||
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
|
||||||
page_size: clampInt(num(args, "page_size"), 50, 1, 500)
|
|
||||||
};
|
|
||||||
putStr(params, args, ["source", "keyword"]);
|
|
||||||
return client.get("/v1/cli/targets", params, signal);
|
|
||||||
}
|
|
||||||
async function findTopologyPaths(client, args, signal) {
|
|
||||||
const fromUid = str(args, "from_ume_ne_id").trim();
|
|
||||||
const fromMid = str(args, "from_managed_ne_id").trim();
|
|
||||||
const toUid = str(args, "to_ume_ne_id").trim();
|
|
||||||
const toMid = str(args, "to_managed_ne_id").trim();
|
|
||||||
if (Boolean(fromUid) === Boolean(fromMid)) {
|
|
||||||
return { ok: false, error: "exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required" };
|
|
||||||
}
|
|
||||||
if (Boolean(toUid) === Boolean(toMid)) {
|
|
||||||
return { ok: false, error: "exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required" };
|
|
||||||
}
|
|
||||||
let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary";
|
|
||||||
if (detail !== "summary" && detail !== "full")
|
|
||||||
detail = "summary";
|
|
||||||
const body = {
|
|
||||||
max_paths: clampInt(num(args, "max_paths"), 3, 1, 10),
|
|
||||||
max_hops: clampInt(num(args, "max_hops"), 6, 1, 12),
|
|
||||||
layer: str(args, "layer", "physical").trim() || "physical",
|
|
||||||
detail
|
|
||||||
};
|
|
||||||
if (fromUid)
|
|
||||||
body.from_ume_ne_id = fromUid;
|
|
||||||
else
|
|
||||||
body.from_managed_ne_id = fromMid;
|
|
||||||
if (toUid)
|
|
||||||
body.to_ume_ne_id = toUid;
|
|
||||||
else
|
|
||||||
body.to_managed_ne_id = toMid;
|
|
||||||
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/netx/tools.ts
|
|
||||||
var str2 = (description) => ({ type: "string", ...description ? { description } : {} });
|
|
||||||
var num2 = (description) => ({ type: "number", ...description ? { description } : {} });
|
|
||||||
var bool2 = (description) => ({ type: "boolean", ...description ? { description } : {} });
|
|
||||||
var strArr = (description) => ({
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
...description ? { description } : {}
|
|
||||||
});
|
|
||||||
function renderJson(_args, value) {
|
|
||||||
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
|
|
||||||
}
|
|
||||||
var jsonOut = {
|
|
||||||
schema: { type: "json" },
|
|
||||||
render: renderJson
|
|
||||||
};
|
|
||||||
function tool(name, description, parameters, handler, getClient, timeoutMs) {
|
|
||||||
return defineTool({
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
parameters,
|
|
||||||
output: jsonOut,
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function registerNetxTools(ctx, connection) {
|
|
||||||
const client = createNetxClient({
|
|
||||||
apiUrl: connection.apiUrl,
|
|
||||||
token: connection.token,
|
|
||||||
lang: connection.lang,
|
|
||||||
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000)
|
|
||||||
});
|
|
||||||
const getClient = () => client;
|
|
||||||
const t = connection.toolCallTimeoutMs;
|
|
||||||
const disposers = [
|
|
||||||
ctx.tools.register(tool("netx__queryUmeAlarms", "Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", {
|
|
||||||
severity: str2(),
|
|
||||||
ne_id: str2("Filter only; do not show UUID to users"),
|
|
||||||
host_name: str2("Filter by NE host_name"),
|
|
||||||
ne_name: str2("Legacy alias mapped to keyword"),
|
|
||||||
keyword: str2("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."),
|
|
||||||
time_from: str2("ISO time; filters last_seen_at >="),
|
|
||||||
time_to: str2("ISO time; filters last_seen_at <="),
|
|
||||||
page: num2(),
|
|
||||||
page_size: num2()
|
|
||||||
}, queryUmeAlarms, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__aggregateUmeAlarms", "Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", {
|
|
||||||
severity: str2("Optional perceived_severity filter (critical/major/minor/warning)."),
|
|
||||||
top_ne: num2("Max NE buckets (default 50). Ignored when group_by is set."),
|
|
||||||
exclude_missing_host: bool2("Omit missing host_name from by_ne."),
|
|
||||||
time_from: str2(),
|
|
||||||
time_to: str2(),
|
|
||||||
group_by: str2("When set, routes to raw aggregation. Prefer alarm_host_name."),
|
|
||||||
group_by2: str2(),
|
|
||||||
is_cleared: str2(),
|
|
||||||
ne_id: str2(),
|
|
||||||
event_type: str2(),
|
|
||||||
keyword: str2(),
|
|
||||||
limit: num2()
|
|
||||||
}, aggregateUmeAlarms, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__runUmeDiagnostics", "UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__queryUmeNeInventory", "Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", {
|
|
||||||
keyword: str2(),
|
|
||||||
page: num2(),
|
|
||||||
page_size: num2()
|
|
||||||
}, queryUmeNeInventory, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__getUmeNe", "Get single UME NE detail by ne_id (UUID).", {
|
|
||||||
ne_id: { type: "string", required: true, description: "UME inventory ne_id (UUID)." }
|
|
||||||
}, getUmeNe, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__queryUmeAlarmsRaw", "Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", {
|
|
||||||
severity: str2(),
|
|
||||||
is_cleared: str2(),
|
|
||||||
ne_id: str2(),
|
|
||||||
event_type: str2(),
|
|
||||||
keyword: str2(),
|
|
||||||
time_from: str2(),
|
|
||||||
time_to: str2(),
|
|
||||||
order_by: str2("last_seen_at | time_created | perceived_severity | event_type | ne_id"),
|
|
||||||
order: str2("asc | desc"),
|
|
||||||
select_fields: strArr(),
|
|
||||||
field_preset: str2("brief | evidence | ne_debug"),
|
|
||||||
page: num2(),
|
|
||||||
page_size: num2()
|
|
||||||
}, queryUmeAlarmsRaw, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__aggregateUmeAlarmsRaw", "Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.", {
|
|
||||||
group_by: { type: "string", required: true },
|
|
||||||
group_by2: str2(),
|
|
||||||
severity: str2(),
|
|
||||||
is_cleared: str2(),
|
|
||||||
ne_id: str2(),
|
|
||||||
event_type: str2(),
|
|
||||||
keyword: str2(),
|
|
||||||
time_from: str2(),
|
|
||||||
time_to: str2(),
|
|
||||||
exclude_missing_host: bool2(),
|
|
||||||
limit: num2()
|
|
||||||
}, aggregateUmeAlarmsRaw, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__listUmeAlarmFields", "List available fields for UME raw alarm queries.", {}, listUmeAlarmFields, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__sqlQueryUme", "Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", {
|
|
||||||
sql: { type: "string", required: true },
|
|
||||||
limit: num2(),
|
|
||||||
statement_timeout_ms: num2()
|
|
||||||
}, sqlQueryUme, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", {
|
|
||||||
keyword: str2(),
|
|
||||||
vendor: str2(),
|
|
||||||
connect_status: str2("unknown | testing | pass | fail"),
|
|
||||||
page: num2(),
|
|
||||||
page_size: num2()
|
|
||||||
}, listManagedNe, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.", {
|
|
||||||
ne_id: str2("Managed NE id"),
|
|
||||||
managed_ne_id: str2("Alias for ne_id"),
|
|
||||||
id: str2("Alias for ne_id")
|
|
||||||
}, getManagedNe, getClient, t)),
|
|
||||||
ctx.tools.register(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.", {
|
|
||||||
ne_id: str2(),
|
|
||||||
ume_ne_id: str2(),
|
|
||||||
ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."),
|
|
||||||
ume_ne_ids: strArr("UME inventory ne_ids for concurrent batch (shared commands)."),
|
|
||||||
targets: {
|
|
||||||
type: "array",
|
|
||||||
description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
ne_id: str2(),
|
|
||||||
ume_ne_id: str2(),
|
|
||||||
commands: strArr()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
commands: strArr("Commands for single NE, or shared commands for batch."),
|
|
||||||
read_timeout_sec: num2("Per-command read timeout (default 60; use 90–120 for slow show)."),
|
|
||||||
concurrency: num2("Parallel NEs for batch mode (1–8, default 4)."),
|
|
||||||
async: bool2("oclaw-only async hint; ignored by native REST client.")
|
|
||||||
}, execManagedNe, getClient, Math.max(t, 300000))),
|
|
||||||
ctx.tools.register(tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", {
|
|
||||||
source: str2("managed | ume | all"),
|
|
||||||
keyword: str2(),
|
|
||||||
page: num2(),
|
|
||||||
page_size: num2()
|
|
||||||
}, listCliTargets, getClient, t)),
|
|
||||||
ctx.tools.register(tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", {
|
|
||||||
from_ume_ne_id: str2(),
|
|
||||||
from_managed_ne_id: str2(),
|
|
||||||
to_ume_ne_id: str2(),
|
|
||||||
to_managed_ne_id: str2(),
|
|
||||||
max_paths: num2(),
|
|
||||||
max_hops: num2(),
|
|
||||||
layer: str2(),
|
|
||||||
detail: str2("summary | full")
|
|
||||||
}, findTopologyPaths, getClient, t))
|
|
||||||
];
|
|
||||||
return () => {
|
|
||||||
for (const dispose of disposers)
|
|
||||||
dispose();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/index.ts
|
// src/index.ts
|
||||||
var name = "netxops";
|
var name = "netxops";
|
||||||
var inject = ["tools"];
|
|
||||||
var NETXOPS_SETTINGS_NAMESPACE = "netxops";
|
var NETXOPS_SETTINGS_NAMESPACE = "netxops";
|
||||||
var NETXOPS_PRESET_ID = "netxops";
|
var NETXOPS_PRESET_ID = "netxops";
|
||||||
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
||||||
|
|
@ -698,56 +87,48 @@ function installNetxopsSettings(ctx, entry, hooks) {
|
||||||
}
|
}
|
||||||
function apply(ctx, config = Config({})) {
|
function apply(ctx, config = Config({})) {
|
||||||
let source = () => config;
|
let source = () => config;
|
||||||
let unregister;
|
let publishing = Promise.resolve();
|
||||||
let remounting = Promise.resolve();
|
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
if (config.installAgentPreset) {
|
if (config.installAgentPreset) {
|
||||||
ensureAgentPresetInstalled(ctx.logger);
|
ensureAgentPresetInstalled(ctx.logger);
|
||||||
}
|
}
|
||||||
const remount = () => {
|
const publish = () => {
|
||||||
remounting = remounting.then(async () => {
|
publishing = publishing.then(async () => {
|
||||||
const gen = ++generation;
|
const gen = ++generation;
|
||||||
unregister?.();
|
|
||||||
unregister = undefined;
|
|
||||||
if (gen !== generation)
|
|
||||||
return;
|
|
||||||
const current = source();
|
const current = source();
|
||||||
const token = await resolveToken(ctx, current.tokenCredentialRef);
|
const token = await resolveToken(ctx, current.tokenCredentialRef);
|
||||||
if (gen !== generation)
|
if (gen !== generation)
|
||||||
return;
|
return;
|
||||||
unregister = registerNetxTools(ctx, {
|
publishNetxConnection({
|
||||||
apiUrl: current.apiUrl.replace(/\/$/, ""),
|
apiUrl: current.apiUrl.replace(/\/$/, ""),
|
||||||
token,
|
token,
|
||||||
lang: current.lang,
|
lang: current.lang,
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs
|
toolCallTimeoutMs: current.toolCallTimeoutMs
|
||||||
});
|
});
|
||||||
ctx.logger.info("netxops: registered netx__* REST tools → %s", current.apiUrl.replace(/\/$/, ""));
|
ctx.logger.info("netxops: published connection → %s", current.apiUrl.replace(/\/$/, ""));
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
ctx.logger.error("netxops: remount error: %s", error);
|
ctx.logger.error("netxops: connection publish error: %s", error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
remount();
|
publish();
|
||||||
installNetxopsSettings(ctx, config, {
|
installNetxopsSettings(ctx, config, {
|
||||||
setSource: (current) => {
|
setSource: (current) => {
|
||||||
source = current;
|
source = current;
|
||||||
},
|
},
|
||||||
onChange: () => {
|
onChange: () => {
|
||||||
remount();
|
publish();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ctx.on("credentials/reference-updated", (ref) => {
|
ctx.on("credentials/reference-updated", (ref) => {
|
||||||
if (String(ref) === source().tokenCredentialRef)
|
if (String(ref) === source().tokenCredentialRef)
|
||||||
remount();
|
publish();
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
unregister?.();
|
}, "netxops: dispose host bridge");
|
||||||
unregister = undefined;
|
|
||||||
}, "netxops: dispose netx tools");
|
|
||||||
}
|
}
|
||||||
export {
|
export {
|
||||||
name,
|
name,
|
||||||
inject,
|
|
||||||
ensureAgentPresetInstalled,
|
ensureAgentPresetInstalled,
|
||||||
apply,
|
apply,
|
||||||
NETXOPS_SETTINGS_NAMESPACE,
|
NETXOPS_SETTINGS_NAMESPACE,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-netxops",
|
"name": "dsh-netxops",
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"description": "DeepSeek Harness Netx Ops: native netx__* REST tools + Plugins settings card + agent preset",
|
"description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + Plugins settings card + agent preset",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
"main": "./lib/index.js",
|
"main": "./lib/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./lib/index.js",
|
".": "./lib/index.js",
|
||||||
|
"./tools": "./lib/agent-tools.js",
|
||||||
"./client": "./lib/client.js",
|
"./client": "./lib/client.js",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/",
|
"src/",
|
||||||
"lib/index.js",
|
"lib/index.js",
|
||||||
|
"lib/agent-tools.js",
|
||||||
"lib/client.js",
|
"lib/client.js",
|
||||||
"presets/",
|
"presets/",
|
||||||
"cordis.patch.yml",
|
"cordis.patch.yml",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Netx Ops agent preset — UME alarms / NE inventory / managed CLI.
|
# Netx Ops agent preset — UME alarms / NE inventory / managed CLI.
|
||||||
# Host keeps registries, sandbox, model route; this file owns persona + skills.
|
# Host keeps registries, sandbox, model route, settings; this file owns
|
||||||
# netx__* tools are registered on the HOST by package `dsh-netxops`.
|
# persona, skills, and Ops-scoped `netx__*` tools.
|
||||||
|
|
||||||
# ── identity ────────────────────────────────────────────────────────────────
|
# ── identity ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -64,6 +64,9 @@
|
||||||
- id: tool-skill
|
- id: tool-skill
|
||||||
name: '@deepseek-ai/dsh-tool-skill'
|
name: '@deepseek-ai/dsh-tool-skill'
|
||||||
|
|
||||||
# netx__* tools are mounted on the HOST by package `dsh-netxops` (cordis.patch.yml).
|
# Ops-only tools: register into this preset's tool scope (not the host global layer).
|
||||||
# The same package copies this preset into `$DSH_HOME/.agent-presets/netxops`
|
- id: netxops-tools
|
||||||
|
name: dsh-netxops/tools
|
||||||
|
|
||||||
|
# Host package `dsh-netxops` copies this preset into `$DSH_HOME/.agent-presets/netxops`
|
||||||
# on activate — no manual link-preset script for normal installs.
|
# on activate — no manual link-preset script for normal installs.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
/**
|
/**
|
||||||
* Build lib/index.js (host Cordis plugin) for installs under node_modules.
|
* Build lib/index.js (host) and lib/agent-tools.js (Ops preset tool mount).
|
||||||
* Node refuses to strip TypeScript inside node_modules, so GitHub/npm installs
|
* Node refuses to strip TypeScript inside node_modules, so GitHub/npm installs
|
||||||
* must ship prebuilt JS. Usage: bun run scripts/build-host.mjs
|
* must ship prebuilt JS. Usage: bun run scripts/build-host.mjs
|
||||||
*/
|
*/
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
import { basename, dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const entry = join(root, 'src/index.ts')
|
const outDir = join(root, 'lib')
|
||||||
const outFile = join(root, 'lib', 'index.js')
|
|
||||||
|
|
||||||
const external = [
|
const external = [
|
||||||
'@deepseek-ai/cordis',
|
'@deepseek-ai/cordis',
|
||||||
|
|
@ -19,21 +18,29 @@ const external = [
|
||||||
'@deepseek-ai/dsh-tools',
|
'@deepseek-ai/dsh-tools',
|
||||||
]
|
]
|
||||||
|
|
||||||
const result = await Bun.build({
|
const entries = [
|
||||||
entrypoints: [entry],
|
{ entry: join(root, 'src/index.ts'), out: join(outDir, 'index.js') },
|
||||||
target: 'node',
|
{ entry: join(root, 'src/agent-tools.ts'), out: join(outDir, 'agent-tools.js') },
|
||||||
format: 'esm',
|
]
|
||||||
sourcemap: 'none',
|
|
||||||
minify: false,
|
|
||||||
external,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.success) {
|
mkdirSync(outDir, { recursive: true })
|
||||||
console.error(result.logs)
|
|
||||||
throw new Error('Bun.build host failed')
|
for (const { entry, out } of entries) {
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [entry],
|
||||||
|
target: 'node',
|
||||||
|
format: 'esm',
|
||||||
|
sourcemap: 'none',
|
||||||
|
minify: false,
|
||||||
|
external,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(result.logs)
|
||||||
|
throw new Error(`Bun.build host failed for ${basename(entry)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await result.outputs[0].text()
|
||||||
|
writeFileSync(out, text, 'utf8')
|
||||||
|
console.log(`wrote ${out} (${text.length} bytes)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await result.outputs[0].text()
|
|
||||||
mkdirSync(dirname(outFile), { recursive: true })
|
|
||||||
writeFileSync(outFile, text, 'utf8')
|
|
||||||
console.log(`wrote ${outFile} (${text.length} bytes)`)
|
|
||||||
|
|
|
||||||
45
src/agent-tools.ts
Normal file
45
src/agent-tools.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Agent-plane Netx Ops tools: register `netx__*` into the calling context's
|
||||||
|
* tool scope (the Netx Ops preset standing mount), so other presets do not see them.
|
||||||
|
*
|
||||||
|
* @module dsh-netxops/tools
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import type {} from '@deepseek-ai/dsh-tools'
|
||||||
|
import { getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
|
||||||
|
import { registerNetxTools } from './netx/tools.ts'
|
||||||
|
|
||||||
|
/** Cordis plugin name. */
|
||||||
|
export const name = 'netxops-tools'
|
||||||
|
|
||||||
|
/** Tool registry must exist in this (preset-scoped) context. */
|
||||||
|
export const inject = ['tools']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount netx REST tools for the Netx Ops agent preset only.
|
||||||
|
*/
|
||||||
|
export function apply(ctx: Context): void {
|
||||||
|
let unregister: (() => void) | undefined
|
||||||
|
|
||||||
|
const remount = (): void => {
|
||||||
|
unregister?.()
|
||||||
|
unregister = undefined
|
||||||
|
const connection = getNetxConnection()
|
||||||
|
if (connection === undefined) {
|
||||||
|
ctx.logger.warn('netxops-tools: no connection yet — waiting for host settings bridge')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unregister = registerNetxTools(ctx, connection)
|
||||||
|
ctx.logger.info('netxops-tools: registered netx__* for Ops preset → %s', connection.apiUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
remount()
|
||||||
|
const stopWatch = watchNetxConnection(() => { remount() })
|
||||||
|
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
stopWatch()
|
||||||
|
unregister?.()
|
||||||
|
unregister = undefined
|
||||||
|
}, 'netxops-tools: dispose')
|
||||||
|
}
|
||||||
38
src/index.ts
38
src/index.ts
|
|
@ -1,6 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Host-plane Netx Ops: settings (apiUrl / lang) + credentials (NETX_API_TOKEN)
|
* Host-plane Netx Ops: settings (apiUrl / lang) + credentials (NETX_API_TOKEN)
|
||||||
* drive native `netx__*` tools that call the netx REST API directly.
|
* publish a connection snapshot; the Ops preset mounts `netx__*` into its own
|
||||||
|
* tool scope (`dsh-netxops/tools`) so other agents do not see them.
|
||||||
*
|
*
|
||||||
* On activate, the agent preset + skills are copied into
|
* On activate, the agent preset + skills are copied into
|
||||||
* `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough
|
* `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough
|
||||||
|
|
@ -19,15 +20,11 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||||
import type {} from '@deepseek-ai/dsh-credentials'
|
import type {} from '@deepseek-ai/dsh-credentials'
|
||||||
import * as DshSettings from '@deepseek-ai/dsh-settings'
|
import * as DshSettings from '@deepseek-ai/dsh-settings'
|
||||||
import type {} from '@deepseek-ai/dsh-settings'
|
import type {} from '@deepseek-ai/dsh-settings'
|
||||||
import type {} from '@deepseek-ai/dsh-tools'
|
import { publishNetxConnection } from './netx/runtime.ts'
|
||||||
import { registerNetxTools } from './netx/tools.ts'
|
|
||||||
|
|
||||||
/** Cordis plugin name. */
|
/** Cordis plugin name. */
|
||||||
export const name = 'netxops'
|
export const name = 'netxops'
|
||||||
|
|
||||||
/** Tool registry must exist to register `netx__*` tools. */
|
|
||||||
export const inject = ['tools']
|
|
||||||
|
|
||||||
/** Settings / composition namespace (Plugins page join key). */
|
/** Settings / composition namespace (Plugins page join key). */
|
||||||
export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
|
export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
|
||||||
|
|
||||||
|
|
@ -145,59 +142,52 @@ function installNetxopsSettings(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the Netx Ops host bridge.
|
* Apply the Netx Ops host bridge (settings + connection publish; tools live on the preset).
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context, config: Config = Config({})): void {
|
export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
let source: () => Config = () => config
|
let source: () => Config = () => config
|
||||||
let unregister: (() => void) | undefined
|
let publishing: Promise<void> = Promise.resolve()
|
||||||
let remounting: Promise<void> = Promise.resolve()
|
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
|
||||||
if (config.installAgentPreset) {
|
if (config.installAgentPreset) {
|
||||||
ensureAgentPresetInstalled(ctx.logger)
|
ensureAgentPresetInstalled(ctx.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
const remount = (): void => {
|
const publish = (): void => {
|
||||||
remounting = remounting.then(async () => {
|
publishing = publishing.then(async () => {
|
||||||
const gen = ++generation
|
const gen = ++generation
|
||||||
unregister?.()
|
|
||||||
unregister = undefined
|
|
||||||
if (gen !== generation) return
|
|
||||||
|
|
||||||
const current = source()
|
const current = source()
|
||||||
const token = await resolveToken(ctx, current.tokenCredentialRef)
|
const token = await resolveToken(ctx, current.tokenCredentialRef)
|
||||||
if (gen !== generation) return
|
if (gen !== generation) return
|
||||||
|
|
||||||
unregister = registerNetxTools(ctx, {
|
publishNetxConnection({
|
||||||
apiUrl: current.apiUrl.replace(/\/$/, ''),
|
apiUrl: current.apiUrl.replace(/\/$/, ''),
|
||||||
token,
|
token,
|
||||||
lang: current.lang,
|
lang: current.lang,
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||||
})
|
})
|
||||||
ctx.logger.info('netxops: registered netx__* REST tools → %s', current.apiUrl.replace(/\/$/, ''))
|
ctx.logger.info('netxops: published connection → %s', current.apiUrl.replace(/\/$/, ''))
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
ctx.logger.error('netxops: remount error: %s', error)
|
ctx.logger.error('netxops: connection publish error: %s', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
remount()
|
publish()
|
||||||
|
|
||||||
installNetxopsSettings(ctx, config, {
|
installNetxopsSettings(ctx, config, {
|
||||||
setSource: (current) => {
|
setSource: (current) => {
|
||||||
source = current
|
source = current
|
||||||
},
|
},
|
||||||
onChange: () => {
|
onChange: () => {
|
||||||
remount()
|
publish()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.on('credentials/reference-updated', (ref) => {
|
ctx.on('credentials/reference-updated', (ref) => {
|
||||||
if (String(ref) === source().tokenCredentialRef) remount()
|
if (String(ref) === source().tokenCredentialRef) publish()
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1
|
generation += 1
|
||||||
unregister?.()
|
}, 'netxops: dispose host bridge')
|
||||||
unregister = undefined
|
|
||||||
}, 'netxops: dispose netx tools')
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
src/netx/runtime.ts
Normal file
54
src/netx/runtime.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Process-local Netx Ops connection snapshot shared between the host settings
|
||||||
|
* bridge and the Ops-preset-scoped tool plugin.
|
||||||
|
*
|
||||||
|
* Uses `Symbol.for` on `globalThis` so host + agent-tools bundles share one store
|
||||||
|
* even when Bun emits them as separate ESM files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NetxToolConnection } from './tools.ts'
|
||||||
|
|
||||||
|
type Listener = () => void
|
||||||
|
|
||||||
|
interface Store {
|
||||||
|
connection: NetxToolConnection | undefined
|
||||||
|
listeners: Set<Listener>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_KEY = Symbol.for('dsh-netxops.connection-store')
|
||||||
|
|
||||||
|
function store(): Store {
|
||||||
|
const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store }
|
||||||
|
let current = root[STORE_KEY]
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { connection: undefined, listeners: new Set() }
|
||||||
|
root[STORE_KEY] = current
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish the latest API URL / token / lang / timeout for Ops tool mounts.
|
||||||
|
* @param next - connection used by the next `registerNetxTools` call.
|
||||||
|
*/
|
||||||
|
export function publishNetxConnection(next: NetxToolConnection): void {
|
||||||
|
const state = store()
|
||||||
|
state.connection = next
|
||||||
|
for (const listener of state.listeners) listener()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns the last published connection, if any. */
|
||||||
|
export function getNetxConnection(): NetxToolConnection | undefined {
|
||||||
|
return store().connection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to connection publishes (settings / credential remounts).
|
||||||
|
* @param listener - called synchronously after each publish.
|
||||||
|
* @returns disposer.
|
||||||
|
*/
|
||||||
|
export function watchNetxConnection(listener: Listener): () => void {
|
||||||
|
const state = store()
|
||||||
|
state.listeners.add(listener)
|
||||||
|
return () => { state.listeners.delete(listener) }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue