@f5xc-salesdemos/xcsh 18.68.0 → 18.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.68.0",
4
+ "version": "18.70.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -48,12 +48,12 @@
48
48
  "dependencies": {
49
49
  "@agentclientprotocol/sdk": "0.16.1",
50
50
  "@mozilla/readability": "^0.6",
51
- "@f5xc-salesdemos/xcsh-stats": "18.68.0",
52
- "@f5xc-salesdemos/pi-agent-core": "18.68.0",
53
- "@f5xc-salesdemos/pi-ai": "18.68.0",
54
- "@f5xc-salesdemos/pi-natives": "18.68.0",
55
- "@f5xc-salesdemos/pi-tui": "18.68.0",
56
- "@f5xc-salesdemos/pi-utils": "18.68.0",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.70.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.70.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.70.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.70.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.70.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.70.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.68.0",
21
- "commit": "27ddc863792ce7bba1ab75066abeca95cafca4d4",
22
- "shortCommit": "27ddc86",
20
+ "version": "18.70.0",
21
+ "commit": "34f16142c582f937c72feae361239b82a67657df",
22
+ "shortCommit": "34f1614",
23
23
  "branch": "main",
24
- "tag": "v18.68.0",
25
- "commitDate": "2026-05-18T17:51:04Z",
26
- "buildDate": "2026-05-18T18:22:44.227Z",
24
+ "tag": "v18.70.0",
25
+ "commitDate": "2026-05-19T06:13:53Z",
26
+ "buildDate": "2026-05-19T06:39:53.619Z",
27
27
  "dirty": false,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/27ddc863792ce7bba1ab75066abeca95cafca4d4",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.68.0"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/34f16142c582f937c72feae361239b82a67657df",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.70.0"
33
33
  };
@@ -0,0 +1,405 @@
1
+ /**
2
+ * pipeline-report/benchmark.ts
3
+ *
4
+ * Autoresearch benchmark harness for the pipeline report.
5
+ * Generates a real pipeline report against the live Salesforce org
6
+ * and scores it deterministically across 6 quality dimensions.
7
+ *
8
+ * Primary metric: report_quality_score (0–100, higher is better)
9
+ *
10
+ * OFF LIMITS to autoresearch modifications — this is the ground-truth evaluator.
11
+ */
12
+
13
+ import { loadSalesforceContext } from "../internal-urls/salesforce-context";
14
+ import { loadProfile } from "../internal-urls/user-profile";
15
+ import { generatePipelineReport } from "./generator";
16
+ import { renderPipelineReport } from "./renderer";
17
+ import type { PipelineReportData } from "./types";
18
+
19
+ // ─── Fiscal quarter helpers ───────────────────────────────────────────
20
+ // F5 fiscal year starts November 1. Quarters:
21
+ // Q1: Nov–Jan Q2: Feb–Apr Q3: May–Jul Q4: Aug–Oct
22
+
23
+ function fiscalQuarterDates(): { start: string; end: string } {
24
+ const now = new Date();
25
+ const m = now.getMonth(); // 0-indexed
26
+ const y = now.getFullYear();
27
+
28
+ let start: Date;
29
+ let end: Date;
30
+
31
+ if (m >= 10) {
32
+ // Nov–Dec → Q1 starts this year Nov, ends Jan next year
33
+ start = new Date(y, 10, 1);
34
+ end = new Date(y + 1, 1, 0);
35
+ } else if (m === 0) {
36
+ // Jan → still Q1 (Nov prev year – Jan this year)
37
+ start = new Date(y - 1, 10, 1);
38
+ end = new Date(y, 1, 0);
39
+ } else if (m <= 3) {
40
+ // Feb–Apr → Q2
41
+ start = new Date(y, 1, 1);
42
+ end = new Date(y, 4, 0);
43
+ } else if (m <= 6) {
44
+ // May–Jul → Q3
45
+ start = new Date(y, 4, 1);
46
+ end = new Date(y, 7, 0);
47
+ } else {
48
+ // Aug–Oct → Q4
49
+ start = new Date(y, 7, 1);
50
+ end = new Date(y, 10, 0);
51
+ }
52
+
53
+ const fmt = (d: Date) => d.toISOString().split("T")[0]!;
54
+ return { start: fmt(start), end: fmt(end) };
55
+ }
56
+
57
+ // ─── Scoring helpers ──────────────────────────────────────────────────
58
+
59
+ interface DimScore {
60
+ score: number;
61
+ max: number;
62
+ notes: string[];
63
+ }
64
+
65
+ function scoreSectionPresence(report: string): DimScore {
66
+ const checks: Array<{ re: RegExp; label: string; pts: number }> = [
67
+ { re: /^# F5 Distributed Cloud Pipeline Report/m, label: "report header", pts: 2 },
68
+ { re: /\*\*Generated:\*\*/m, label: "generated timestamp", pts: 2 },
69
+ { re: /\*\*Quarter:\*\*/m, label: "quarter range", pts: 2 },
70
+ { re: /\*\*Summary:\*\*/m, label: "executive summary", pts: 3 },
71
+ { re: /Closed.*Booked/m, label: "booked section", pts: 3 },
72
+ { re: /Open Pipeline.*Net New/m, label: "net new section", pts: 3 },
73
+ { re: /Open Pipeline.*Renewals/m, label: "renewals section", pts: 3 },
74
+ { re: /Forecast Summary/m, label: "forecast summary", pts: 3 },
75
+ { re: /\*\*Line items:\*\*/m, label: "line item count", pts: 1 },
76
+ { re: /\*\*SKUs:\*\*/m, label: "SKU count", pts: 1 },
77
+ { re: /\*\*Model:\*\*/m, label: "value model description", pts: 1 },
78
+ ];
79
+ let score = 0;
80
+ let max = 0;
81
+ const notes: string[] = [];
82
+ for (const c of checks) {
83
+ max += c.pts;
84
+ if (c.re.test(report)) {
85
+ score += c.pts;
86
+ } else {
87
+ notes.push(`Missing: ${c.label}`);
88
+ }
89
+ }
90
+ return { score, max, notes };
91
+ }
92
+
93
+ function scoreDollarFormatting(report: string): DimScore {
94
+ const notes: string[] = [];
95
+ let score = 0;
96
+ const max = 20;
97
+
98
+ // Compact dollar amounts in summaries ($1.2M, $45K)
99
+ if (/\$[\d,.]+[MK]\b/.test(report)) {
100
+ score += 5;
101
+ } else {
102
+ notes.push("No compact dollar amounts ($XM/$XK) in summary lines");
103
+ }
104
+
105
+ // Precise dollar amounts in tables (1,234.56)
106
+ if (/[\d,]+\.\d{2}/.test(report)) {
107
+ score += 5;
108
+ } else {
109
+ notes.push("No precise dollar amounts (X,XXX.XX) in tables");
110
+ }
111
+
112
+ // Em-dash (—) for zero values instead of literal $0
113
+ if (/\|\s*—\s*\|/.test(report)) {
114
+ score += 3;
115
+ }
116
+
117
+ // No unformatted large numbers in table context
118
+ const rawLarge = [...(report.matchAll(/(?<!\$)(?<!\d[,.])\b\d{5,}\b(?!\.\d{2})(?![MK%])/g) ?? [])].filter(m => {
119
+ const ctx = report.slice(Math.max(0, m.index! - 20), m.index! + m[0].length + 20);
120
+ return ctx.includes("|");
121
+ });
122
+ if (rawLarge.length === 0) {
123
+ score += 4;
124
+ } else {
125
+ notes.push(`${rawLarge.length} unformatted large number(s) in table context`);
126
+ }
127
+
128
+ // No undefined/null/NaN in output
129
+ const badValues = (report.match(/\bundefined\b|\bnull\b|\bNaN\b/g) ?? []).length;
130
+ if (badValues === 0) {
131
+ score += 3;
132
+ } else {
133
+ notes.push(`${badValues} occurrence(s) of undefined/null/NaN in output`);
134
+ }
135
+
136
+ return { score, max, notes };
137
+ }
138
+
139
+ function scoreDateFormatting(report: string): DimScore {
140
+ const notes: string[] = [];
141
+ let score = 0;
142
+ const max = 10;
143
+
144
+ // Generated timestamp: human-readable, not raw ISO
145
+ const generatedMatch = report.match(/\*\*Generated:\*\*\s*(.+)/);
146
+ if (generatedMatch) {
147
+ const d = generatedMatch[1]!.trim();
148
+ if (/^\d{4}-\d{2}-\d{2}T/.test(d)) {
149
+ notes.push("Generated date uses raw ISO 8601 (should be locale string)");
150
+ } else {
151
+ score += 3;
152
+ }
153
+ }
154
+
155
+ // Quarter line present and reasonably readable
156
+ const quarterMatch = report.match(/\*\*Quarter:\*\*\s*(.+)/);
157
+ if (quarterMatch) {
158
+ score += quarterMatch[1]!.includes("-") ? 3 : 4;
159
+ }
160
+
161
+ // No raw ISO timestamps (2026-05-18T...) in the report body
162
+ const rawTs = (report.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g) ?? []).length;
163
+ if (rawTs === 0) {
164
+ score += 3;
165
+ } else {
166
+ notes.push(`${rawTs} raw ISO timestamp(s) in report body`);
167
+ }
168
+
169
+ return { score, max, notes };
170
+ }
171
+
172
+ function scoreTableQuality(report: string): DimScore {
173
+ const notes: string[] = [];
174
+ let score = 0;
175
+ const max = 15;
176
+
177
+ const tables = report.match(/\|.+\|[\n\r]+\|[-:|]+\|/g) ?? [];
178
+ if (tables.length === 0) {
179
+ notes.push("No properly formatted markdown tables found");
180
+ return { score: 0, max, notes };
181
+ }
182
+ score += 3;
183
+
184
+ // All tables have separator rows (already implied by regex, this checks alignment chars)
185
+ const hasRightAlign = /---:\|/.test(report);
186
+ if (hasRightAlign) {
187
+ score += 3;
188
+ } else {
189
+ notes.push("No right-aligned columns (amounts should be right-aligned with ---:|)");
190
+ }
191
+
192
+ // Tables have left-aligned label columns
193
+ if (/:---\|/.test(report)) {
194
+ score += 3;
195
+ }
196
+
197
+ // Territory grouping separators (bold em-dash rows)
198
+ if (/\*\*.*—.*\*\*/.test(report)) {
199
+ score += 3;
200
+ }
201
+
202
+ // Total rows (bold)
203
+ if (/\|\s*\*\*Total\*\*\s*\|/.test(report)) {
204
+ score += 3;
205
+ } else {
206
+ notes.push("No bold Total rows in tables");
207
+ }
208
+
209
+ return { score, max, notes };
210
+ }
211
+
212
+ function scoreDataDensity(data: PipelineReportData, report: string): DimScore {
213
+ const notes: string[] = [];
214
+ let score = 0;
215
+ const max = 20;
216
+
217
+ if (data.lineItemCount > 0) {
218
+ score += 4;
219
+ } else {
220
+ notes.push("Zero line items processed");
221
+ }
222
+
223
+ const totalAccounts = data.netNew.accounts.length + data.booked.accounts.length + data.renewals.accounts.length;
224
+ if (totalAccounts > 0) {
225
+ score += 4;
226
+ } else {
227
+ notes.push("No accounts in any section");
228
+ }
229
+
230
+ const fc = data.forecast;
231
+ const fcCount = [fc.commit, fc.bestCase, fc.pipeline].filter(v => v > 0).length;
232
+ score += Math.min(5, fcCount * 2);
233
+ if (fcCount === 0) {
234
+ notes.push("No forecast category amounts populated");
235
+ }
236
+
237
+ if (data.skusFound.length > 0) {
238
+ score += 3;
239
+ } else {
240
+ notes.push("No SKUs discovered");
241
+ }
242
+
243
+ // Anomaly detection running (even 0 anomalies means the engine ran)
244
+ score += 2;
245
+ if (data.anomalies.length > 0 && /Data Quality/.test(report)) {
246
+ score += 2;
247
+ }
248
+
249
+ return { score, max, notes };
250
+ }
251
+
252
+ function scoreInformationValue(report: string): DimScore {
253
+ const notes: string[] = [];
254
+ let score = 0;
255
+ const max = 10;
256
+
257
+ // Quota total callouts (actionable for sales professionals)
258
+ if (/Quota Total/i.test(report)) {
259
+ score += 3;
260
+ } else {
261
+ notes.push("No quota total callouts");
262
+ }
263
+
264
+ // Forecast category breakdown visible (Commit, Best Case, Pipeline)
265
+ if (/Commit/m.test(report) && /Best Case/m.test(report)) {
266
+ score += 2;
267
+ }
268
+
269
+ // Platform vs Point product split (critical for overlay SE reporting)
270
+ if (/Platform.*Distributed Cloud/m.test(report) || /Point.*Shape/m.test(report)) {
271
+ score += 3;
272
+ } else {
273
+ notes.push("Platform/Point product split not visible");
274
+ }
275
+
276
+ // Territory context present
277
+ if (/Territories:/m.test(report) || /Territory/m.test(report)) {
278
+ score += 2;
279
+ }
280
+
281
+ return { score, max, notes };
282
+ }
283
+
284
+ // ─── Main ─────────────────────────────────────────────────────────────
285
+
286
+ const profile = await loadProfile();
287
+ const sfContext = await loadSalesforceContext();
288
+
289
+ if (!profile.identifiers?.salesforceId) {
290
+ console.error("ERROR: No salesforceId in user profile. Run: xcsh://salesforce?refresh=true");
291
+ process.exit(1);
292
+ }
293
+
294
+ if (!sfContext) {
295
+ console.error("ERROR: No cached Salesforce context. Run: xcsh://salesforce?refresh=true");
296
+ process.exit(1);
297
+ }
298
+
299
+ const userId = profile.identifiers.salesforceId;
300
+ const partnerId = profile.partner?.id;
301
+ const userIds = partnerId ? [userId, partnerId] : [userId];
302
+ const { start, end } = fiscalQuarterDates();
303
+
304
+ // Stale cutoff: 12 months ago to include non-stale pipeline beyond current quarter
305
+ const staleDate = new Date();
306
+ staleDate.setFullYear(staleDate.getFullYear() - 1);
307
+ const staleCutoff = staleDate.toISOString().split("T")[0]!;
308
+
309
+ const partnerName = profile.partner?.name;
310
+ const selfName = [profile.givenName, profile.familyName].filter(Boolean).join(" ").trim();
311
+ const teamMemberNames = partnerName && selfName ? [selfName, partnerName] : undefined;
312
+
313
+ const options = {
314
+ userIds,
315
+ orgAlias: sfContext.orgAlias,
316
+ quarterStart: start,
317
+ quarterEnd: end,
318
+ staleCutoff,
319
+ confirmedTerritories: profile.territories ?? sfContext.confirmedTerritories ?? sfContext.territories,
320
+ teamMemberNames,
321
+ };
322
+
323
+ // ─── Generate and time ────────────────────────────────────────────────
324
+
325
+ const genStart = performance.now();
326
+ const data = await generatePipelineReport(options);
327
+ const generateMs = Math.round(performance.now() - genStart);
328
+
329
+ const renderStart = performance.now();
330
+ const report = renderPipelineReport(data, sfContext.instanceUrl);
331
+ const renderMs = Math.round(performance.now() - renderStart);
332
+
333
+ const totalMs = generateMs + renderMs;
334
+
335
+ // ─── Score ────────────────────────────────────────────────────────────
336
+
337
+ const sections = scoreSectionPresence(report);
338
+ const dollars = scoreDollarFormatting(report);
339
+ const dates = scoreDateFormatting(report);
340
+ const tables = scoreTableQuality(report);
341
+ const density = scoreDataDensity(data, report);
342
+ const infoVal = scoreInformationValue(report);
343
+
344
+ const rawScore = sections.score + dollars.score + dates.score + tables.score + density.score + infoVal.score;
345
+ const rawMax = sections.max + dollars.max + dates.max + tables.max + density.max + infoVal.max;
346
+ const qualityScore = Math.round((rawScore / rawMax) * 100);
347
+
348
+ const dataCompletenessPct = Math.round(
349
+ ([
350
+ data.generated,
351
+ data.quarter.start,
352
+ data.quarter.end,
353
+ data.lineItemCount > 0,
354
+ data.skusFound.length > 0,
355
+ data.netNew.accounts.length > 0 || data.booked.accounts.length > 0,
356
+ data.forecast.commit > 0 || data.forecast.bestCase > 0 || data.forecast.pipeline > 0,
357
+ data.territories.length > 0,
358
+ ].filter(Boolean).length /
359
+ 8) *
360
+ 100,
361
+ );
362
+
363
+ // Baseline generator makes 3 queries: net new + booked (parallel) + renewals (sequential)
364
+ const queryCount = 3;
365
+
366
+ const totalAccounts = data.netNew.accounts.length + data.booked.accounts.length + data.renewals.accounts.length;
367
+
368
+ // ─── Output METRIC lines ──────────────────────────────────────────────
369
+
370
+ console.log(`METRIC report_quality_score=${qualityScore}`);
371
+ console.log(`METRIC query_count=${queryCount}`);
372
+ console.log(`METRIC total_time_ms=${totalMs}`);
373
+ console.log(`METRIC generate_time_ms=${generateMs}`);
374
+ console.log(`METRIC render_time_ms=${renderMs}`);
375
+ console.log(`METRIC data_completeness_pct=${dataCompletenessPct}`);
376
+ console.log(`METRIC section_score=${sections.score}`);
377
+ console.log(`METRIC dollar_format_score=${dollars.score}`);
378
+ console.log(`METRIC date_format_score=${dates.score}`);
379
+ console.log(`METRIC table_alignment_score=${tables.score}`);
380
+ console.log(`METRIC data_density_score=${density.score}`);
381
+ console.log(`METRIC info_value_score=${infoVal.score}`);
382
+ console.log(`METRIC line_item_count=${data.lineItemCount}`);
383
+ console.log(`METRIC account_count=${totalAccounts}`);
384
+ console.log(`METRIC report_length=${report.length}`);
385
+ console.log(
386
+ `ASI scoring_breakdown={"sections":${sections.score}/${sections.max},"dollars":${dollars.score}/${dollars.max},"dates":${dates.score}/${dates.max},"tables":${tables.score}/${tables.max},"density":${density.score}/${density.max},"infoValue":${infoVal.score}/${infoVal.max},"total":${rawScore}/${rawMax}}`,
387
+ );
388
+
389
+ const allNotes = [
390
+ ...sections.notes.map(n => `[sections] ${n}`),
391
+ ...dollars.notes.map(n => `[dollars] ${n}`),
392
+ ...dates.notes.map(n => `[dates] ${n}`),
393
+ ...tables.notes.map(n => `[tables] ${n}`),
394
+ ...density.notes.map(n => `[density] ${n}`),
395
+ ...infoVal.notes.map(n => `[info-value] ${n}`),
396
+ ];
397
+
398
+ if (allNotes.length > 0) {
399
+ console.log(`ASI deduction_notes=${JSON.stringify(allNotes)}`);
400
+ console.log("");
401
+ console.log("Deduction notes:");
402
+ for (const note of allNotes) {
403
+ console.log(` - ${note}`);
404
+ }
405
+ }
@@ -19,3 +19,6 @@ Response format:
19
19
  GET requests auto-retry once on transient errors (429/503) after 1s backoff. POST/PUT/DELETE are never retried.
20
20
  API calls to the same F5 XC tenant reuse a single TLS connection — sequential calls are faster than parallel calls.
21
21
  **Namespace discovery**: When asked about resources in a namespace, you **MUST** use `paths: ["*"]` to auto-discover and batch all namespace resource types in ONE call. Do NOT enumerate types individually.
22
+
23
+ **Relationship queries**: When the batch response says "Inventory complete" and includes a `Resource relationships:` section, the specs and relationships are already fully fetched. Answer directly from that data. Do NOT make additional GET calls to read individual resources you already have from the batch.
24
+ **Tenant-wide queries**: When asked about resources across ALL namespaces (e.g. "show all LBs in the entire tenant"), use `paths: ["*"]` with `params: {namespace: "*"}` to batch every namespace in ONE call. Do NOT list namespaces first — the wildcard handles discovery automatically.
@@ -84,7 +84,7 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
84
84
  #contextEnv: ContextEnv;
85
85
  #lastApiBase = "";
86
86
  #listablePathsCache: string[] | null = null;
87
- #autoExpandDone = false;
87
+ #expandedNamespaces = new Set<string>();
88
88
 
89
89
  constructor(session: ToolSession) {
90
90
  this.description = prompt.render(xcshApiDescription);
@@ -158,6 +158,79 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
158
158
  }
159
159
  }
160
160
 
161
+ /** Batch ALL non-system namespaces in one tool call for tenant-wide queries. */
162
+ async #executeMultiNamespaceBatch(
163
+ paths: string[],
164
+ apiBase: string,
165
+ apiToken: string,
166
+ signal?: AbortSignal,
167
+ ): Promise<XcshApiResult> {
168
+ const headers = { Authorization: `APIToken ${apiToken}`, Accept: "application/json" };
169
+ const timeoutSignal = AbortSignal.timeout(90_000);
170
+ const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
171
+ const startMs = performance.now();
172
+
173
+ // Discover all accessible namespaces
174
+ let allNs: string[] = [];
175
+ try {
176
+ const nsResp = await fetch(`${apiBase}/api/web/namespaces`, {
177
+ method: "GET",
178
+ headers,
179
+ signal: fetchSignal,
180
+ });
181
+ if (nsResp.ok) {
182
+ const nsData = (await nsResp.json()) as { items?: Array<Record<string, unknown>> };
183
+ allNs = (nsData.items ?? [])
184
+ .map(item => (typeof item.name === "string" ? item.name : null))
185
+ .filter(
186
+ (name): name is string =>
187
+ name !== null &&
188
+ !name.startsWith("ves-io") &&
189
+ !name.startsWith("system") &&
190
+ !name.startsWith("shared"),
191
+ );
192
+ }
193
+ } catch {
194
+ // Fall back to default namespace only
195
+ const def = this.#contextEnv.get("F5XC_NAMESPACE") ?? "default";
196
+ allNs = [def];
197
+ }
198
+
199
+ // Batch each namespace and combine results
200
+ const nsSections: string[] = [];
201
+ for (const ns of allNs) {
202
+ const nsParams = { namespace: ns };
203
+ this.#expandedNamespaces.add(ns);
204
+ const result = await this.#executeBatch(paths, nsParams, apiBase, apiToken, signal);
205
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { text: string }).text : "";
206
+ if (text.includes("resource type")) {
207
+ nsSections.push(`\n=== Namespace: ${ns} ===\n${text}`);
208
+ }
209
+ }
210
+
211
+ const durationMs = Math.round(performance.now() - startMs);
212
+ const combined =
213
+ nsSections.length > 0
214
+ ? nsSections.join("\n") +
215
+ "\n\nTenant-wide inventory complete. All namespaces, specs, and relationships shown above. No further API calls needed."
216
+ : "No resources found across accessible namespaces.";
217
+
218
+ return {
219
+ content: [{ type: "text", text: combined }],
220
+ details: {
221
+ status: 200,
222
+ url: apiBase,
223
+ method: "GET",
224
+ requestId: crypto.randomUUID(),
225
+ durationMs,
226
+ contextName: this.#contextEnv.getContextName(),
227
+ batchSize: paths.length * allNs.length,
228
+ batchSuccessCount: nsSections.length,
229
+ batchTotalItems: 0,
230
+ },
231
+ };
232
+ }
233
+
161
234
  async #executeBatch(
162
235
  paths: string[],
163
236
  params: Record<string, string> | undefined,
@@ -293,17 +366,23 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
293
366
 
294
367
  // Phase 2: fetch individual resource specs for items in non-empty types.
295
368
  // With 42-path batch, this is ~12 items total — adds ~3s, not 100s like the 136-path era.
296
- // Gives the model detailed config (WAF rules, policy rules, etc.) so Q4 doesn't need follow-ups.
297
- const specCache = new Map<string, string>();
369
+ // Store raw spec objects for semantic summary generation
370
+ const rawSpecs = new Map<string, Record<string, unknown>>();
298
371
  const specItems: Array<{ typePath: string; name: string }> = [];
372
+ // Only fetch specs for types that carry relationship data (LBs, pools, firewalls,
373
+ // healthchecks). Excludes system-generated objects (routes, virtual_hosts, etc.)
374
+ // that inflate the item count and don't add relationship info.
375
+ const SPEC_TYPES = /loadbalancer|origin_pool|app_firewall|healthcheck/i;
299
376
  for (const r of relevantData) {
377
+ const typeName = r.path.split("/").pop() ?? "";
378
+ if (!SPEC_TYPES.test(typeName)) continue;
300
379
  const items = (r.parsed?.items as Array<Record<string, unknown>> | undefined) ?? [];
301
380
  for (const item of items) {
302
381
  const name = typeof item.name === "string" ? item.name : null;
303
- if (name && items.length <= 10) specItems.push({ typePath: r.path, name });
382
+ if (name && items.length <= 15) specItems.push({ typePath: r.path, name });
304
383
  }
305
384
  }
306
- if (specItems.length > 0 && specItems.length <= 20 && !fetchSignal.aborted) {
385
+ if (specItems.length > 0 && specItems.length <= 40 && !fetchSignal.aborted) {
307
386
  for (let i = 0; i < specItems.length; i += CONCURRENCY) {
308
387
  const chunk = specItems.slice(i, i + CONCURRENCY);
309
388
  await Promise.all(
@@ -315,17 +394,7 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
315
394
  const data = (await resp.json()) as Record<string, unknown>;
316
395
  const spec = data.spec as Record<string, unknown> | undefined;
317
396
  if (spec) {
318
- // Compact top-level spec summary
319
- const summary = Object.entries(spec)
320
- .filter(([, v]) => v != null && v !== "" && !(Array.isArray(v) && v.length === 0))
321
- .slice(0, 3)
322
- .map(([k, v]) => {
323
- if (typeof v === "object" && !Array.isArray(v)) return k;
324
- if (Array.isArray(v)) return `${k}[${v.length}]`;
325
- return `${k}=${String(v).slice(0, 30)}`;
326
- })
327
- .join(", ");
328
- specCache.set(`${typePath}/${name}`, summary);
397
+ rawSpecs.set(`${typePath}/${name}`, spec);
329
398
  }
330
399
  }
331
400
  } catch {
@@ -339,48 +408,102 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
339
408
 
340
409
  // Compact response: names only for discovery, no full JSON blobs
341
410
  const sections: string[] = [];
342
- // Categorize: app/security types are prominent, infrastructure types are secondary.
343
- // Pattern-based categorization using naming conventions, not hardcoded type lists.
344
- // META_EXCLUDE: meta-policy types (rule sets, data policy) are secondary to direct app resources.
345
- const APP_KEYWORDS =
346
- /loadbalancer|pool|firewall|_policys|setting|type|mitigation|identification|network|route|host|definition|rate_limiter|prefix_set|cdn|waf|api_/i;
347
- const META_EXCLUDE = /policy_set|policy_rule|data_polic/i;
411
+ // Split types into core (relationship-bearing, with specs) and secondary.
412
+ // Core types get detailed per-resource output with spec summaries.
413
+ // Secondary types get a compact count to reduce batch response noise.
414
+ // Shorter, focused output helps the model find relationship data directly.
348
415
  const getTypeName = (r: BatchEntry) => r.path.split("/").pop() ?? r.path;
349
- const appTypes = relevantData.filter(r => {
350
- const n = getTypeName(r);
351
- return APP_KEYWORDS.test(n) && !META_EXCLUDE.test(n);
352
- });
353
- const infraTypes = relevantData.filter(r => {
354
- const n = getTypeName(r);
355
- return !APP_KEYWORDS.test(n) || META_EXCLUDE.test(n);
356
- });
357
-
358
- // Numbered list format with explicit stop signal
359
- if (appTypes.length > 0) {
360
- sections.push(`Namespace resource inventory (${appTypes.length} resource types with data):\n`);
416
+ const coreTypes = relevantData.filter(r => SPEC_TYPES.test(getTypeName(r)));
417
+ const secondaryTypes = relevantData.filter(r => !SPEC_TYPES.test(getTypeName(r)));
418
+
419
+ if (coreTypes.length > 0) {
420
+ sections.push(`Namespace resource inventory (${coreTypes.length} core types):\n`);
361
421
  let idx = 1;
362
- for (const r of appTypes) {
422
+ for (const r of coreTypes) {
363
423
  const typeName = getTypeName(r);
364
424
  const items = (r.parsed?.items as Array<Record<string, unknown>> | undefined) ?? [];
365
- const itemSummaries = items.map(item => {
366
- const name = typeof item.name === "string" ? item.name : "?";
367
- const desc = typeof item.description === "string" && item.description ? ` (${item.description})` : "";
368
- const disabled = item.disabled === true ? " [DISABLED]" : "";
369
- const spec = specCache.get(`${r.path}/${name}`);
370
- const specStr = spec ? ` \u2014 ${spec}` : "";
371
- return `${name}${desc}${disabled}${specStr}`;
372
- });
373
- const nameStr = itemSummaries.length > 0 ? itemSummaries.join(", ") : `${r.itemCount} item(s)`;
374
- sections.push(`${idx}. ${typeName}: ${nameStr}`);
425
+ if (items.length === 0) {
426
+ sections.push(`${idx}. ${typeName}: ${r.itemCount} item(s)`);
427
+ } else {
428
+ sections.push(`${idx}. ${typeName} (${items.length}):`);
429
+ for (const item of items) {
430
+ const name = typeof item.name === "string" ? item.name : "?";
431
+ const disabled = item.disabled === true ? " [DISABLED]" : "";
432
+ sections.push(` - ${name}${disabled}`);
433
+ }
434
+ }
375
435
  idx++;
376
436
  }
377
437
  }
378
438
 
379
- if (infraTypes.length > 0) {
380
- sections.push(`\n(+${infraTypes.length} infrastructure/policy-meta types omitted)`);
439
+ if (secondaryTypes.length > 0) {
440
+ const secondaryCount = secondaryTypes.reduce((sum, r) => sum + (r.itemCount ?? 0), 0);
441
+ sections.push(
442
+ `\n(+${secondaryTypes.length} other types with ${secondaryCount} items: ${secondaryTypes.map(r => getTypeName(r)).join(", ")})`,
443
+ );
381
444
  }
382
445
 
383
- sections.push("\nInventory complete. Report every numbered item above. No further API calls needed.");
446
+ // Semantic summary: answer-ready labels from raw specs.
447
+ // Human-readable labels (WAF=name, no-WAF, pools=[...]) instead of raw fields.
448
+ // Directly answers relationship queries without follow-up GETs.
449
+ const summaryLines: string[] = [];
450
+ const extractRef = (v: unknown): string | null => {
451
+ if (typeof v === "object" && v !== null && !Array.isArray(v)) {
452
+ const o = v as Record<string, unknown>;
453
+ return typeof o.name === "string" ? o.name : null;
454
+ }
455
+ return null;
456
+ };
457
+ const extractPoolRefs = (pools: unknown): string[] => {
458
+ if (!Array.isArray(pools)) return [];
459
+ return (pools as Array<Record<string, unknown>>)
460
+ .map(item => {
461
+ if (typeof item?.pool === "object" && item.pool !== null)
462
+ return (item.pool as Record<string, unknown>).name as string;
463
+ return extractRef(item);
464
+ })
465
+ .filter((n): n is string => n !== null);
466
+ };
467
+ for (const [specKey, spec] of rawSpecs) {
468
+ const parts = specKey.split("/");
469
+ const rName = parts.at(-1) ?? "";
470
+ const rType = parts.at(-2) ?? "";
471
+ const labels: string[] = [];
472
+ if (/loadbalancer/i.test(rType)) {
473
+ const waf = extractRef(spec.app_firewall);
474
+ labels.push(waf ? `WAF=${waf}` : "no-WAF");
475
+ const pools = extractPoolRefs(spec.default_route_pools);
476
+ if (pools.length > 0) labels.push(`pools=[${pools.join(",")}]`);
477
+ if (spec.disable_rate_limit != null) labels.push("no-rate-limit");
478
+ else if (spec.rate_limit != null) labels.push("has-rate-limit");
479
+ const domains = Array.isArray(spec.domains) ? (spec.domains as string[]) : [];
480
+ if (domains.length > 0) labels.push(`domains=[${domains.join(",")}]`);
481
+ }
482
+ if (/origin_pool/i.test(rType)) {
483
+ const hc = Array.isArray(spec.healthcheck)
484
+ ? (spec.healthcheck as Array<Record<string, unknown>>).map(h => extractRef(h)).filter(Boolean)
485
+ : [];
486
+ labels.push(hc.length > 0 ? `healthcheck=[${hc.join(",")}]` : "no-healthcheck");
487
+ const servers = Array.isArray(spec.origin_servers) ? (spec.origin_servers as unknown[]).length : 0;
488
+ if (servers > 0) labels.push(`${servers} origin(s)`);
489
+ if (typeof spec.port === "number") labels.push(`port=${spec.port}`);
490
+ }
491
+ if (/app_firewall/i.test(rType)) {
492
+ if (spec.detection_settings != null) labels.push("has-detection-settings");
493
+ if (spec.monitoring != null) labels.push("monitoring-mode");
494
+ else if (spec.blocking != null) labels.push("blocking-mode");
495
+ }
496
+ if (labels.length > 0) {
497
+ summaryLines.push(`${rName}: ${labels.join(", ")}`);
498
+ }
499
+ }
500
+ if (summaryLines.length > 0) {
501
+ sections.push(`\nResource summary:\n${summaryLines.join("\n")}`);
502
+ }
503
+
504
+ sections.push(
505
+ "\nInventory complete. Resource summary above contains all relationship data. No further API calls needed.",
506
+ );
384
507
  const batchTotalItems = relevantData.reduce((sum, r) => sum + (r.itemCount ?? 0), 0);
385
508
  const text = sections.join("\n");
386
509
  const batchSize = paths.length;
@@ -457,29 +580,34 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
457
580
  // Wildcard "*" auto-discovers all namespace-scoped list paths from the catalog
458
581
  const resolved = batchPaths.length === 1 && batchPaths[0] === "*" ? this.#loadListablePaths() : batchPaths;
459
582
  if (resolved.length > 0) {
583
+ const batchNs = params.params?.namespace ?? this.#contextEnv.get("F5XC_NAMESPACE") ?? "";
584
+ // Wildcard namespace: batch ALL non-system namespaces in one tool call.
585
+ // Reduces multi-namespace queries from N+1 batch calls to 1.
586
+ if (batchNs === "*") {
587
+ return this.#executeMultiNamespaceBatch(resolved, apiBase, apiToken, signal);
588
+ }
589
+ if (batchNs) this.#expandedNamespaces.add(batchNs);
460
590
  return this.#executeBatch(resolved, params.params, apiBase, apiToken, signal);
461
591
  }
462
592
  }
463
- // Auto-expand: when the model GETs a namespace list endpoint for the first time,
464
- // proactively batch ALL namespace list types for maximum discovery efficiency.
465
- // Only triggers once per session to avoid redundant batch responses.
593
+ // Per-namespace auto-expand: when the model GETs a namespace list endpoint,
594
+ // batch ALL types for that namespace on first access. Each namespace expands once.
466
595
  // File-based cache in #executeBatch prevents redundant API calls across sessions.
467
- if (!this.#autoExpandDone && params.method === "GET" && !params.payload) {
596
+ if (params.method === "GET" && !params.payload) {
468
597
  const listablePaths = this.#loadListablePaths();
469
598
  if (listablePaths.length > 0) {
470
- // Normalize: replace actual namespace in path with {namespace} for matching
471
599
  const normalized = params.path.replace(
472
600
  /\/api\/config\/namespaces\/[^/]+\//,
473
601
  "/api/config/namespaces/{namespace}/",
474
602
  );
475
603
  if (listablePaths.includes(params.path) || listablePaths.includes(normalized)) {
476
- // Extract namespace from resolved path if params don't already have it
477
604
  const nsMatch = params.path.match(/\/api\/config\/namespaces\/([^/]+)\//);
478
- const batchParams =
479
- params.params ??
480
- (nsMatch?.[1] && nsMatch[1] !== "{namespace}" ? { namespace: nsMatch[1] } : undefined);
481
- this.#autoExpandDone = true;
482
- return this.#executeBatch(listablePaths, batchParams, apiBase, apiToken, signal);
605
+ const ns = params.params?.namespace ?? (nsMatch?.[1] && nsMatch[1] !== "{namespace}" ? nsMatch[1] : "");
606
+ if (ns && !this.#expandedNamespaces.has(ns)) {
607
+ const batchParams = params.params ?? { namespace: ns };
608
+ this.#expandedNamespaces.add(ns);
609
+ return this.#executeBatch(listablePaths, batchParams, apiBase, apiToken, signal);
610
+ }
483
611
  }
484
612
  }
485
613
  }