Ship capability groups (nms/common/topology) with shared netx skills.

One group maps to one skill and tools-*; rename NMS tools to *Nms*; sync playbooks from netx/skills.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-06 01:46:07 +08:00
parent 83c3796fbb
commit 3c9a383071
47 changed files with 11790 additions and 680 deletions

View file

@ -4,15 +4,24 @@
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import {
groupsForPlane,
toolNamesForGroups,
type NetxCapabilityGroupId,
type NetxCapabilityGroups,
} from './capability-groups.ts'
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { getNetxConnection } from './runtime.ts'
import * as H from './handlers.ts'
import * as T from './topology-handlers.ts'
export interface NetxToolConnection {
apiUrl: string
token: string
lang: string
toolCallTimeoutMs: number
/** Capability-group exposure; omitted → package defaults. */
groups?: NetxCapabilityGroups
}
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
@ -60,14 +69,36 @@ function tool(
})
}
export interface RegisterNetxToolsOptions {
/**
* `preset` — groups with `inPreset` (Netx Ops preset scope).
* `public` — groups with `public` (host layer for other presets).
*/
plane: 'preset' | 'public'
/** Optional intersect with settings-enabled groups. */
only?: readonly NetxCapabilityGroupId[]
/** When set, register exactly these groups (ignore plane flags). */
forceGroups?: readonly NetxCapabilityGroupId[]
}
/**
* Register all Netx Ops tools against the current connection snapshot.
* Register Netx Ops tools for one exposure plane against the connection snapshot.
* Bearer is read from the live process store on each request (not frozen here).
* @param ctx - host context with `tools`.
* @param connection - apiUrl / token / lang / timeout (apiUrl/lang/timeout used for client base).
* @param ctx - context with `tools`.
* @param connection - apiUrl / token / lang / timeout / groups.
* @param options - which plane's group flags to honor.
* @returns disposer that unregisters every tool.
*/
export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void {
export function registerNetxTools(
ctx: Context,
connection: NetxToolConnection,
options: RegisterNetxToolsOptions,
): () => void {
const enabledGroups = options.forceGroups
?? groupsForPlane(connection.groups, options.plane, options.only)
const allow = toolNamesForGroups(enabledGroups)
if (allow.size === 0) return () => {}
const client = createNetxClient({
apiUrl: connection.apiUrl,
lang: connection.lang,
@ -77,10 +108,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
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.',
const catalog = [
tool(
'netx__queryNmsAlarms',
'Query NMS 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: str(),
ne_id: str('Filter only; do not show UUID to users'),
@ -93,10 +124,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
page_size: num(),
},
H.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.',
),
tool(
'netx__aggregateNmsAlarms',
'Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
{
severity: str('Optional perceived_severity filter (critical/major/minor/warning).'),
top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'),
@ -112,34 +143,34 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
limit: num(),
},
H.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.',
),
tool(
'netx__runNmsDiagnostics',
'NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
{},
H.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).',
),
tool(
'netx__queryNmsNeInventory',
'Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
{
keyword: str(),
page: num(),
page_size: num(),
},
H.queryUmeNeInventory, getClient, t,
)),
ctx.tools.register(tool(
'netx__getUmeNe',
'Get single UME NE detail by ne_id (UUID).',
),
tool(
'netx__getNmsNe',
'Get single NMS NE detail by ne_id (UUID).',
{
ne_id: { type: 'string' as const, required: true as const, description: 'UME inventory ne_id (UUID).' },
ne_id: { type: 'string' as const, required: true as const, description: 'NMS inventory ne_id (UUID).' },
},
H.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.',
),
tool(
'netx__queryNmsAlarmsRaw',
'Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
{
severity: str(),
is_cleared: str(),
@ -156,10 +187,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
page_size: num(),
},
H.queryUmeAlarmsRaw, getClient, t,
)),
ctx.tools.register(tool(
'netx__aggregateUmeAlarmsRaw',
'Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.',
),
tool(
'netx__aggregateNmsAlarmsRaw',
'Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.',
{
group_by: { type: 'string' as const, required: true as const },
group_by2: str(),
@ -174,24 +205,24 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
limit: num(),
},
H.aggregateUmeAlarmsRaw, getClient, t,
)),
ctx.tools.register(tool(
'netx__listUmeAlarmFields',
'List available fields for UME raw alarm queries.',
),
tool(
'netx__listNmsAlarmFields',
'List available fields for NMS raw alarm queries.',
{},
H.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.',
),
tool(
'netx__sqlQueryNms',
'Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
{
sql: { type: 'string' as const, required: true as const },
limit: num(),
statement_timeout_ms: num(),
},
H.sqlQueryUme, getClient, t,
)),
ctx.tools.register(tool(
),
tool(
'netx__listManagedNe',
'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.',
{
@ -202,25 +233,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
page_size: num(),
},
H.listManagedNe, getClient, t,
)),
ctx.tools.register(tool(
),
tool(
'netx__getManagedNe',
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.',
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.',
{
ne_id: str('Managed NE id'),
managed_ne_id: str('Alias for ne_id'),
id: str('Alias for ne_id'),
},
H.getManagedNe, getClient, t,
)),
ctx.tools.register(tool(
),
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: str(),
ume_ne_id: str(),
nms_ne_id: str('NMS inventory id; alias ume_ne_id'),
ume_ne_id: str('Legacy alias of nms_ne_id'),
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
ume_ne_ids: strArr('UME inventory ne_ids for concurrent batch (shared commands).'),
nms_ne_ids: strArr('NMS inventory ids; alias ume_ne_ids'),
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[].',
@ -240,25 +273,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
async: bool('oclaw-only async hint; ignored by native REST client.'),
},
H.execManagedNe, getClient, Math.max(t, 300_000),
)),
ctx.tools.register(tool(
),
tool(
'netx__listCliTargets',
'List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
{
source: str('managed | ume | all'),
source: str('managed | nms | ume | all'),
keyword: str(),
page: num(),
page_size: num(),
},
H.listCliTargets, getClient, t,
)),
ctx.tools.register(tool(
),
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.',
'Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
{
from_ume_ne_id: str(),
from_nms_ne_id: str('NMS inventory id'),
from_ume_ne_id: str('Legacy alias of from_nms_ne_id'),
from_managed_ne_id: str(),
to_ume_ne_id: str(),
to_nms_ne_id: str('NMS inventory id'),
to_ume_ne_id: str('Legacy alias of to_nms_ne_id'),
to_managed_ne_id: str(),
max_paths: num(),
max_hops: num(),
@ -266,9 +301,249 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
detail: str('summary | full'),
},
H.findTopologyPaths, getClient, t,
)),
),
// ── topology group (netx-topology canvas / fabric) ─────────────────────
tool(
'netx__getTopologyTree',
'Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.',
{ compact: bool(), max_depth: num() },
T.getTopologyTree, getClient, t,
),
tool(
'netx__getTopologyView',
'Get a topology view by view_id. Default detail=summary with sample_nodes + links[].',
{
view_id: { type: 'string' as const, required: true as const },
detail: str('summary | full'),
sample: num(),
},
T.getTopologyView, getClient, t,
),
tool(
'netx__createTopologyFolder',
'Create topology folders / region canvases. Only way to create canvases (ne:write).',
{
name: { type: 'string' as const, required: true as const },
parent_id: str(),
locale: str('zh | en'),
sort_order: num(),
},
T.createTopologyFolder, getClient, t,
),
tool(
'netx__addTopologyViewNodes',
'Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).',
{
view_id: { type: 'string' as const, required: true as const },
max_nodes: num(),
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
limit: num(),
offset: num(),
fabric_node_ids: strArr(),
layout: str('grid | keep'),
},
T.addTopologyViewNodes, getClient, t,
),
tool(
'netx__removeTopologyViewNodes',
'Remove placements from a view (not fabric) by filters or fabric_node_ids.',
{
view_id: { type: 'string' as const, required: true as const },
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
fabric_node_ids: strArr(),
},
T.removeTopologyViewNodes, getClient, t,
),
tool(
'netx__copyTopologyViewNodes',
'Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).',
{
source_view_id: { type: 'string' as const, required: true as const },
target_view_id: { type: 'string' as const, required: true as const },
copy_positions: bool(),
clear_target: bool(),
offset_x: num(),
offset_y: num(),
limit: num(),
dry_run: bool(),
},
T.copyTopologyViewNodes, getClient, t,
),
tool(
'netx__updateTopologyViewPositions',
'Move nodes on a view via positions[] or layout=grid|offset|stack + filters.',
{
view_id: { type: 'string' as const, required: true as const },
positions: { type: 'array' as const, items: { type: 'object' as const } },
layout: str('grid | offset | stack'),
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
fabric_node_ids: strArr(),
offset_x: num(),
offset_y: num(),
},
T.updateTopologyViewPositions, getClient, t,
),
tool(
'netx__projectTopologyNeighbors',
'Project existing fabric neighbors of on-view nodes onto the canvas.',
{
view_id: { type: 'string' as const, required: true as const },
seed_fabric_node_ids: strArr(),
managed_ne_ids: strArr(),
region_folder_id: str(),
detail: str('summary | full'),
sample: num(),
},
T.projectTopologyNeighbors, getClient, t,
),
tool(
'netx__queryTopologyFabricNodes',
'Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).',
{
mode: str('summary | list | search'),
q: str(),
keyword: str(),
role: str(),
level: str(),
level_major: str(),
region_folder_id: str(),
link_status: str(),
page: num(),
page_size: num(),
limit: num(),
summary: bool(),
},
T.queryTopologyFabricNodes, getClient, t,
),
tool(
'netx__classifyTopologyFabricNodes',
'Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.',
{
action: { type: 'string' as const, required: true as const },
pattern: str(),
q: str(),
match_field: str(),
sample_limit: num(),
fabric_node_ids: strArr(),
fabric_node_id: str(),
node_id: str(),
level: str(),
role: str(),
region_folder_id: str(),
clear_region: bool(),
dry_run: bool(),
kind: str(),
page: num(),
page_size: num(),
skip_manual: bool(),
overwrite_manual: bool(),
fill_empty_only: bool(),
},
T.classifyTopologyFabricNodes, getClient, t,
),
tool(
'netx__queryTopologyNeighborhood',
'Neighborhood around a fabric node (depth 1–3) with compact links[].',
{
node_id: { type: 'string' as const, required: true as const },
depth: num(),
layer: str(),
},
T.queryTopologyNeighborhood, getClient, t,
),
tool(
'netx__queryTopologyEdges',
'Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).',
{
node_id: str(),
keyword: str(),
layer: str(),
status: str(),
source: str('lldp | ume | manual'),
detail: str('adjacency | ports'),
page: num(),
page_size: num(),
},
T.queryTopologyEdges, getClient, t,
),
tool(
'netx__suggestSinkHubs',
'Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).',
{
source_view_id: str(),
view_id: str(),
pick: num(),
exclude_portal_ids: strArr(),
exclude_fabric_node_ids: strArr(),
},
T.suggestSinkHubs, getClient, t,
),
tool(
'netx__analyzeTopologyViewLayout',
'Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.',
{
view_id: str(),
folder_id: str(),
detail: str('summary | structure | hotspots | blocks | both'),
score_profile: str(),
sight_limit: num(),
max_views: num(),
min_nodes: num(),
max_nodes: num(),
with_meta: bool(),
},
T.analyzeTopologyViewLayout, getClient, t,
),
tool(
'netx__sinkTopologyDualUnits',
'Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).',
{
source_view_id: str(),
sink_view_id: str(),
max_units: num(),
min_nodes: num(),
max_nodes: num(),
max_batch_nodes: num(),
layout_batch: bool(),
until_empty: bool(),
dry_run: bool(),
},
T.sinkTopologyDualUnits, getClient, t,
),
tool(
'netx__layoutTopologyView',
'Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.',
{
view_id: str(),
action: str(),
source_view_id: str(),
recipe: str(),
preset: str(),
mode: str('preview | apply'),
tune: bool(),
params: { type: 'object' as const },
catalog: bool(),
fabric_node_ids: strArr(),
park: bool(),
copy_positions: bool(),
},
T.layoutTopologyView, getClient, Math.max(t, 180_000),
),
]
const disposers = catalog
.filter((entry) => allow.has(entry.name))
.map((entry) => ctx.tools.register(entry))
return () => {
for (const dispose of disposers) dispose()
}