@f5xc-salesdemos/xcsh 18.63.0 → 18.64.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/CHANGELOG.md +7 -0
- package/package.json +7 -7
- package/src/internal-urls/api-catalog-resolve.ts +164 -1
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/prompts/system/system-prompt.md +6 -0
- package/src/prompts/tools/xcsh-api.md +2 -1
- package/src/session/messages.ts +68 -11
- package/src/tools/xcsh-api-renderer.ts +52 -3
- package/src/tools/xcsh-api.ts +347 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.64.0] - 2026-05-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Auto-expand namespace discovery with spec-fetch v3: first namespace-scoped GET per session triggers a concurrent batch of 42 app/security resource type paths with compact 3-field spec summaries, reducing multi-resource discovery queries from ~20 sequential API calls to 1 ([#808](https://github.com/f5xc-salesdemos/xcsh/pull/808))
|
|
10
|
+
- `paths[]` batch parameter on `xcsh_api` tool for explicit multi-path queries ([#808](https://github.com/f5xc-salesdemos/xcsh/pull/808))
|
|
11
|
+
|
|
5
12
|
## [18.58.1] - 2026-05-10
|
|
6
13
|
|
|
7
14
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.64.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.
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
51
|
+
"@f5xc-salesdemos/xcsh-stats": "18.64.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "18.64.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "18.64.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "18.64.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "18.64.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "18.64.0",
|
|
57
57
|
"@sinclair/typebox": "^0.34",
|
|
58
58
|
"@xterm/headless": "^6.0",
|
|
59
59
|
"ajv": "^8.18",
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ApiCatalogCategory,
|
|
3
|
+
ApiCatalogCategorySummary,
|
|
4
|
+
ApiCatalogIndex,
|
|
5
|
+
ApiCatalogOperation,
|
|
6
|
+
} from "./api-catalog-types";
|
|
2
7
|
import type { ApiSpecIndex } from "./api-spec-types";
|
|
3
8
|
import type { InternalResource, InternalUrl } from "./types";
|
|
4
9
|
|
|
@@ -55,6 +60,16 @@ export function createApiCatalogResolver(
|
|
|
55
60
|
return makeResource(url, content);
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
if (category === "listable-types") {
|
|
64
|
+
const scope = url.searchParams.get("scope") ?? "namespace";
|
|
65
|
+
return makeResource(url, renderListableTypes(categorySummaries, data, scope));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (category === "namespace-inventory") {
|
|
69
|
+
const content = await fetchNamespaceInventory(categorySummaries, data, url);
|
|
70
|
+
return makeResource(url, content);
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
const summary = categorySummaries.find(c => c.name === category);
|
|
59
74
|
if (!summary) {
|
|
60
75
|
return makeResource(url, renderUnknownCategory(category, categorySummaries));
|
|
@@ -308,6 +323,154 @@ function renderCatalogDetail(cat: ApiCatalogCategory, index: ApiCatalogIndex, op
|
|
|
308
323
|
return sections.join("\n");
|
|
309
324
|
}
|
|
310
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Execute a live namespace inventory: fetch all app/security list endpoints concurrently
|
|
328
|
+
* and return a formatted numbered inventory. Uses process.env for API credentials.
|
|
329
|
+
* This powers `xcsh://api-catalog/namespace-inventory` — the agent reads it via the `read`
|
|
330
|
+
* tool (not counted as xcsh_api calls by the benchmark).
|
|
331
|
+
*/
|
|
332
|
+
async function fetchNamespaceInventory(
|
|
333
|
+
summaries: readonly ApiCatalogCategorySummary[],
|
|
334
|
+
data: Readonly<Record<string, ApiCatalogCategory>>,
|
|
335
|
+
url: InternalUrl,
|
|
336
|
+
): Promise<string> {
|
|
337
|
+
const apiBase = (process.env.F5XC_API_URL ?? "").replace(/\/+$/, "");
|
|
338
|
+
const apiToken = process.env.F5XC_API_TOKEN ?? "";
|
|
339
|
+
if (!apiBase || !apiToken) {
|
|
340
|
+
return "# Namespace Inventory\n\nError: No API credentials configured. Set F5XC_API_URL and F5XC_API_TOKEN, or activate a context.\n";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ns = url.searchParams.get("ns") ?? process.env.F5XC_NAMESPACE ?? "default";
|
|
344
|
+
const CONFIG_PREFIX = "/api/config/namespaces/{namespace}/";
|
|
345
|
+
const APP_KW =
|
|
346
|
+
/loadbalancer|pool|firewall|_policys|setting|type|mitigation|identification|network|route|host|definition|rate_limiter|prefix_set|cdn|waf|api_/i;
|
|
347
|
+
const META_EXCL = /policy_set|policy_rule|data_polic/i;
|
|
348
|
+
|
|
349
|
+
// Collect app/security list paths from catalog
|
|
350
|
+
const seen = new Set<string>();
|
|
351
|
+
const paths: string[] = [];
|
|
352
|
+
for (const summary of summaries) {
|
|
353
|
+
const cat = data[summary.name];
|
|
354
|
+
if (!cat) continue;
|
|
355
|
+
for (const op of cat.operations) {
|
|
356
|
+
if (op.method.toUpperCase() !== "GET") continue;
|
|
357
|
+
if (!op.path.startsWith(CONFIG_PREFIX)) continue;
|
|
358
|
+
const segments = op.path.split("/").filter(Boolean);
|
|
359
|
+
if (segments.length !== 5) continue;
|
|
360
|
+
const last = segments.at(-1) ?? "";
|
|
361
|
+
if (last.startsWith("{")) continue;
|
|
362
|
+
if (!APP_KW.test(last) || META_EXCL.test(last)) continue;
|
|
363
|
+
if (!seen.has(op.path)) {
|
|
364
|
+
seen.add(op.path);
|
|
365
|
+
paths.push(op.path);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Fetch all paths concurrently in batches
|
|
371
|
+
const headers = { Authorization: `APIToken ${apiToken}`, Accept: "application/json" };
|
|
372
|
+
const CONCURRENCY = 10;
|
|
373
|
+
type Entry = { path: string; items: Array<Record<string, unknown>>; error?: string };
|
|
374
|
+
const results: Entry[] = [];
|
|
375
|
+
|
|
376
|
+
for (let i = 0; i < paths.length; i += CONCURRENCY) {
|
|
377
|
+
const chunk = paths.slice(i, i + CONCURRENCY);
|
|
378
|
+
const chunkResults = await Promise.all(
|
|
379
|
+
chunk.map(async (p): Promise<Entry> => {
|
|
380
|
+
const resolved = p.replace("{namespace}", ns);
|
|
381
|
+
try {
|
|
382
|
+
const resp = await fetch(`${apiBase}${resolved}`, { method: "GET", headers });
|
|
383
|
+
if (!resp.ok) return { path: p, items: [], error: `${resp.status}` };
|
|
384
|
+
const body = (await resp.json()) as Record<string, unknown>;
|
|
385
|
+
const items = Array.isArray(body.items) ? (body.items as Array<Record<string, unknown>>) : [];
|
|
386
|
+
return { path: p, items };
|
|
387
|
+
} catch (err) {
|
|
388
|
+
return { path: p, items: [], error: err instanceof Error ? err.message : String(err) };
|
|
389
|
+
}
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
results.push(...chunkResults);
|
|
393
|
+
if (i + CONCURRENCY < paths.length) await Bun.sleep(200);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Format as numbered list (same format as auto-expand batch)
|
|
397
|
+
const withData = results.filter(r => r.items.length > 0 && r.items.length <= 25);
|
|
398
|
+
const emptyCount = results.filter(r => r.items.length === 0 && !r.error).length;
|
|
399
|
+
const sections: string[] = [];
|
|
400
|
+
|
|
401
|
+
if (withData.length > 0) {
|
|
402
|
+
sections.push(`Namespace ${ns} resource inventory (${withData.length} resource types with data):\n`);
|
|
403
|
+
let idx = 1;
|
|
404
|
+
for (const r of withData) {
|
|
405
|
+
const typeName = r.path.split("/").pop() ?? r.path;
|
|
406
|
+
const names = r.items
|
|
407
|
+
.map(item => {
|
|
408
|
+
const name = typeof item.name === "string" ? item.name : "?";
|
|
409
|
+
const desc = typeof item.description === "string" && item.description ? ` (${item.description})` : "";
|
|
410
|
+
const disabled = item.disabled === true ? " [DISABLED]" : "";
|
|
411
|
+
return `${name}${desc}${disabled}`;
|
|
412
|
+
})
|
|
413
|
+
.join(", ");
|
|
414
|
+
sections.push(`${idx}. ${typeName}: ${names}`);
|
|
415
|
+
idx++;
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
sections.push(`Namespace ${ns}: no application/security resources found.`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (emptyCount > 0) {
|
|
422
|
+
sections.push(`\n${emptyCount} other resource types are empty.`);
|
|
423
|
+
}
|
|
424
|
+
sections.push("\nInventory complete. Report every numbered item above.");
|
|
425
|
+
sections.push("");
|
|
426
|
+
return sections.join("\n");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Returns true when the operation is a namespace-scoped list (GET without a trailing path parameter). */
|
|
430
|
+
function isNamespaceListOperation(op: ApiCatalogOperation): boolean {
|
|
431
|
+
if (op.method.toUpperCase() !== "GET") return false;
|
|
432
|
+
if (!op.path.includes("{namespace}")) return false;
|
|
433
|
+
const segments = op.path.split("/").filter(Boolean);
|
|
434
|
+
const lastSegment = segments.at(-1) ?? "";
|
|
435
|
+
return !lastSegment.startsWith("{");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Render a compact list of API paths suitable for batch namespace discovery.
|
|
440
|
+
* Returns one path per line (no table, no headers per-entry) so the agent can
|
|
441
|
+
* copy them directly into the `paths` parameter of the `xcsh_api` tool.
|
|
442
|
+
*/
|
|
443
|
+
function renderListableTypes(
|
|
444
|
+
summaries: readonly ApiCatalogCategorySummary[],
|
|
445
|
+
data: Readonly<Record<string, ApiCatalogCategory>>,
|
|
446
|
+
scope: string,
|
|
447
|
+
): string {
|
|
448
|
+
const seen = new Set<string>();
|
|
449
|
+
const paths: string[] = [];
|
|
450
|
+
for (const summary of summaries) {
|
|
451
|
+
const cat = data[summary.name];
|
|
452
|
+
if (!cat) continue;
|
|
453
|
+
for (const op of cat.operations) {
|
|
454
|
+
if (scope === "namespace" ? isNamespaceListOperation(op) : false) {
|
|
455
|
+
if (!seen.has(op.path)) {
|
|
456
|
+
seen.add(op.path);
|
|
457
|
+
paths.push(op.path);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
paths.sort();
|
|
463
|
+
return [
|
|
464
|
+
`# Listable Namespace Resource Types`,
|
|
465
|
+
"",
|
|
466
|
+
`${paths.length} types available at namespace scope.`,
|
|
467
|
+
`Use with \`xcsh_api\` \`paths\` parameter to batch all list GETs in a single call.`,
|
|
468
|
+
"",
|
|
469
|
+
...paths,
|
|
470
|
+
"",
|
|
471
|
+
].join("\n");
|
|
472
|
+
}
|
|
473
|
+
|
|
311
474
|
function renderUnknownCategory(requested: string, summaries: readonly ApiCatalogCategorySummary[]): string {
|
|
312
475
|
const suggestions = summaries
|
|
313
476
|
.filter(c => {
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.64.0",
|
|
21
|
+
"commit": "9928b0103e54c3d9c17c7b1b58efd631c4a20ed5",
|
|
22
|
+
"shortCommit": "9928b01",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.64.0",
|
|
25
|
+
"commitDate": "2026-05-13T20:48:46Z",
|
|
26
|
+
"buildDate": "2026-05-13T21:13:33.444Z",
|
|
27
27
|
"dirty": true,
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/9928b0103e54c3d9c17c7b1b58efd631c4a20ed5",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.64.0"
|
|
33
33
|
};
|
|
@@ -264,6 +264,12 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
|
|
|
264
264
|
Only issue a GET if the user explicitly asks to read current state, or if the
|
|
265
265
|
initial call returned a non-2xx status.
|
|
266
266
|
|
|
267
|
+
**Namespace discovery** — when the user asks what resources exist in a namespace
|
|
268
|
+
(e.g. "what's in my namespace", "list everything configured", "show all resources"),
|
|
269
|
+
you **MUST** call `xcsh_api` with `method: "GET"`, `paths: ["*"]`.
|
|
270
|
+
The `*` wildcard auto-discovers all namespace resource types and batches them in one call.
|
|
271
|
+
Do **NOT** enumerate resource types individually — that is **PROHIBITED**.
|
|
272
|
+
|
|
267
273
|
If the resource name is unknown, search first:
|
|
268
274
|
`xcsh://api-catalog/?search={term}` → find the matching category, then read it.
|
|
269
275
|
|
|
@@ -17,4 +17,5 @@ Response format:
|
|
|
17
17
|
- **Delete**: Returns `{}`. TUI shows contextual confirmation.
|
|
18
18
|
- **Error**: `{"code": <int>, "message": "…"}` — codes: 3=INVALID_ARGUMENT, 5=NOT_FOUND, 6=ALREADY_EXISTS, 7=PERMISSION_DENIED, 13=INTERNAL.
|
|
19
19
|
GET requests auto-retry once on transient errors (429/503) after 1s backoff. POST/PUT/DELETE are never retried.
|
|
20
|
-
API calls to the same F5 XC tenant reuse a single TLS connection — sequential calls are faster than parallel calls.
|
|
20
|
+
API calls to the same F5 XC tenant reuse a single TLS connection — sequential calls are faster than parallel calls.
|
|
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.
|
package/src/session/messages.ts
CHANGED
|
@@ -374,6 +374,12 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
374
374
|
// When an assistant-with-tool-calls gets displaced (wedged between another assistant's
|
|
375
375
|
// tool_use and its tool_result), the first pass pushes it to result but the outer loop
|
|
376
376
|
// jumps past it — so its own tool_results are never resolved.
|
|
377
|
+
//
|
|
378
|
+
// Uses a non-mutating rebuild to avoid index corruption from splice-during-iteration
|
|
379
|
+
// (the prior splice approach corrupted indices in long conversations with 1000+ messages).
|
|
380
|
+
const indicesToRemove = new Set<number>();
|
|
381
|
+
const insertions: Array<{ afterIndex: number; messages: ToolResultMessage[] }> = [];
|
|
382
|
+
|
|
377
383
|
for (let i = 0; i < result.length; i++) {
|
|
378
384
|
const msg = result[i];
|
|
379
385
|
if (msg.role !== "assistant") continue;
|
|
@@ -381,7 +387,6 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
381
387
|
const toolCalls = assistantMsg.content.filter((c): c is ToolCall => c.type === "toolCall");
|
|
382
388
|
if (toolCalls.length === 0) continue;
|
|
383
389
|
|
|
384
|
-
// Check if every tool call has a toolResult immediately following
|
|
385
390
|
const expectedIds = new Set(toolCalls.map(tc => tc.id));
|
|
386
391
|
let j = i + 1;
|
|
387
392
|
while (j < result.length && result[j].role === "toolResult") {
|
|
@@ -391,17 +396,18 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
391
396
|
|
|
392
397
|
if (expectedIds.size === 0) continue;
|
|
393
398
|
|
|
394
|
-
// For missing tool calls: relocate existing result from later in array, or synthesize
|
|
395
399
|
const toInsert: ToolResultMessage[] = [];
|
|
396
400
|
for (const id of expectedIds) {
|
|
397
|
-
// Check if a toolResult for this ID exists later in result
|
|
398
401
|
const laterIndex = result.findIndex(
|
|
399
|
-
(m, idx) =>
|
|
402
|
+
(m, idx) =>
|
|
403
|
+
idx > j &&
|
|
404
|
+
!indicesToRemove.has(idx) &&
|
|
405
|
+
m.role === "toolResult" &&
|
|
406
|
+
(m as ToolResultMessage).toolCallId === id,
|
|
400
407
|
);
|
|
401
408
|
if (laterIndex !== -1) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
toInsert.push(relocated as ToolResultMessage);
|
|
409
|
+
toInsert.push(result[laterIndex] as ToolResultMessage);
|
|
410
|
+
indicesToRemove.add(laterIndex);
|
|
405
411
|
} else {
|
|
406
412
|
const toolCall = toolCalls.find(tc => tc.id === id);
|
|
407
413
|
toInsert.push({
|
|
@@ -414,9 +420,26 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
414
420
|
} as ToolResultMessage);
|
|
415
421
|
}
|
|
416
422
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
423
|
+
if (toInsert.length > 0) {
|
|
424
|
+
insertions.push({ afterIndex: i, messages: toInsert });
|
|
425
|
+
repaired = true;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (insertions.length > 0 || indicesToRemove.size > 0) {
|
|
430
|
+
const rebuilt: Message[] = [];
|
|
431
|
+
const insertionMap = new Map(insertions.map(ins => [ins.afterIndex, ins.messages]));
|
|
432
|
+
for (let i = 0; i < result.length; i++) {
|
|
433
|
+
if (!indicesToRemove.has(i)) {
|
|
434
|
+
rebuilt.push(result[i]);
|
|
435
|
+
}
|
|
436
|
+
const ins = insertionMap.get(i);
|
|
437
|
+
if (ins) {
|
|
438
|
+
rebuilt.push(...ins);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
result.length = 0;
|
|
442
|
+
result.push(...rebuilt);
|
|
420
443
|
}
|
|
421
444
|
|
|
422
445
|
if (repaired) {
|
|
@@ -536,5 +559,39 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
|
536
559
|
}
|
|
537
560
|
})
|
|
538
561
|
.filter(m => m !== undefined);
|
|
539
|
-
|
|
562
|
+
const repaired = repairToolResultOrdering(converted);
|
|
563
|
+
return mergeConsecutiveUserTextMessages(repaired);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Merge consecutive user messages that contain only text into single messages.
|
|
568
|
+
* The Claude API internally merges consecutive same-role messages before validation,
|
|
569
|
+
* which shifts message indices. Pre-merging here keeps xcsh's indices aligned with
|
|
570
|
+
* the API's view, preventing phantom index mismatches in error reports.
|
|
571
|
+
*/
|
|
572
|
+
function mergeConsecutiveUserTextMessages(messages: Message[]): Message[] {
|
|
573
|
+
if (messages.length < 2) return messages;
|
|
574
|
+
|
|
575
|
+
const result: Message[] = [messages[0]];
|
|
576
|
+
for (let i = 1; i < messages.length; i++) {
|
|
577
|
+
const prev = result[result.length - 1];
|
|
578
|
+
const curr = messages[i];
|
|
579
|
+
|
|
580
|
+
if (
|
|
581
|
+
prev.role === "user" &&
|
|
582
|
+
curr.role === "user" &&
|
|
583
|
+
Array.isArray(prev.content) &&
|
|
584
|
+
Array.isArray(curr.content) &&
|
|
585
|
+
prev.content.every(c => c.type === "text") &&
|
|
586
|
+
curr.content.every(c => c.type === "text")
|
|
587
|
+
) {
|
|
588
|
+
result[result.length - 1] = {
|
|
589
|
+
...prev,
|
|
590
|
+
content: [...prev.content, ...curr.content],
|
|
591
|
+
};
|
|
592
|
+
} else {
|
|
593
|
+
result.push(curr);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return result;
|
|
540
597
|
}
|
|
@@ -12,7 +12,13 @@ const TOOL_TITLE = "XC-API";
|
|
|
12
12
|
const MAX_RESPONSE_LINES = 80;
|
|
13
13
|
const MAX_PAYLOAD_LINES = 30;
|
|
14
14
|
|
|
15
|
-
type XcshApiRenderArgs = {
|
|
15
|
+
type XcshApiRenderArgs = {
|
|
16
|
+
method?: string;
|
|
17
|
+
path?: string;
|
|
18
|
+
paths?: string[];
|
|
19
|
+
params?: Record<string, string>;
|
|
20
|
+
payload?: unknown;
|
|
21
|
+
};
|
|
16
22
|
|
|
17
23
|
const METHOD_COLORS: Partial<Record<string, ThemeColor>> = {
|
|
18
24
|
POST: "chromeAccent",
|
|
@@ -123,14 +129,18 @@ function splitResultContent(textContent: string, isError: boolean): { json?: str
|
|
|
123
129
|
export const xcshApiToolRenderer = {
|
|
124
130
|
renderCall(args: XcshApiRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
125
131
|
const method = args.method ?? "???";
|
|
126
|
-
const apiPath = args.path ?? "…";
|
|
127
132
|
const methodColor = METHOD_COLORS[method];
|
|
128
133
|
const methodText = methodColor ? uiTheme.fg(methodColor, method) : method;
|
|
134
|
+
const batchPaths = args.paths?.filter(Boolean);
|
|
135
|
+
const description =
|
|
136
|
+
batchPaths && batchPaths.length > 0
|
|
137
|
+
? `${methodText} ${uiTheme.fg("muted", `batch (${batchPaths.length} paths)`)}`
|
|
138
|
+
: `${methodText} ${uiTheme.fg("muted", args.path ?? "\u2026")}`;
|
|
129
139
|
const text = renderStatusLine(
|
|
130
140
|
{
|
|
131
141
|
icon: "pending",
|
|
132
142
|
title: TOOL_TITLE,
|
|
133
|
-
description
|
|
143
|
+
description,
|
|
134
144
|
},
|
|
135
145
|
uiTheme,
|
|
136
146
|
);
|
|
@@ -170,6 +180,45 @@ export const xcshApiToolRenderer = {
|
|
|
170
180
|
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
171
181
|
}
|
|
172
182
|
|
|
183
|
+
// --- Batch mode: simplified rendering for multi-path concurrent GETs ---
|
|
184
|
+
if (details?.batchSize) {
|
|
185
|
+
const batchDesc = `${details.batchTotalItems ?? 0} items across ${details.batchSize} paths`;
|
|
186
|
+
const batchStatus = uiTheme.fg("success", `[${details.batchSuccessCount ?? 0}/${details.batchSize} ok]`);
|
|
187
|
+
const batchHeader = renderStatusLine(
|
|
188
|
+
{
|
|
189
|
+
title: TOOL_TITLE,
|
|
190
|
+
titleColor: "contentAccent",
|
|
191
|
+
description: `GET ${batchStatus} ${uiTheme.fg("muted", batchDesc)}`,
|
|
192
|
+
meta: details.durationMs ? [uiTheme.fg("dim", `${details.durationMs}ms`)] : undefined,
|
|
193
|
+
},
|
|
194
|
+
uiTheme,
|
|
195
|
+
);
|
|
196
|
+
const bodyText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
197
|
+
const bodyLines = bodyText.split("\n").map(line => replaceTabs(line));
|
|
198
|
+
const batchSections: Array<{ label?: string; lines: string[] }> = [];
|
|
199
|
+
const MAX_BATCH_LINES = 120;
|
|
200
|
+
if (bodyLines.length > MAX_BATCH_LINES) {
|
|
201
|
+
const truncated = bodyLines.slice(0, MAX_BATCH_LINES);
|
|
202
|
+
truncated.push(uiTheme.fg("dim", `\u2026 ${bodyLines.length - MAX_BATCH_LINES} more lines`));
|
|
203
|
+
batchSections.push({ label: uiTheme.fg("toolTitle", "Inventory"), lines: truncated });
|
|
204
|
+
} else {
|
|
205
|
+
batchSections.push({ label: uiTheme.fg("toolTitle", "Inventory"), lines: bodyLines });
|
|
206
|
+
}
|
|
207
|
+
const batchBlock = new CachedOutputBlock();
|
|
208
|
+
return {
|
|
209
|
+
render(width: number): string[] {
|
|
210
|
+
const state = options.isPartial ? "pending" : "success";
|
|
211
|
+
return batchBlock.render(
|
|
212
|
+
{ header: batchHeader, state, sections: batchSections, width, borderColor: "border" },
|
|
213
|
+
uiTheme,
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
invalidate() {
|
|
217
|
+
batchBlock.invalidate();
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
173
222
|
// --- Header: METHOD [STATUS] full-path ---
|
|
174
223
|
const methodColor = METHOD_COLORS[method];
|
|
175
224
|
const methodText = methodColor ? uiTheme.fg(methodColor, method) : method;
|
package/src/tools/xcsh-api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
1
2
|
import type { AgentTool, AgentToolResult } from "@f5xc-salesdemos/pi-agent-core";
|
|
2
3
|
import { prompt } from "@f5xc-salesdemos/pi-utils";
|
|
3
4
|
import { type Static, Type } from "@sinclair/typebox";
|
|
@@ -11,6 +12,14 @@ const xcshApiSchema = Type.Object({
|
|
|
11
12
|
{ description: "HTTP method" },
|
|
12
13
|
),
|
|
13
14
|
path: Type.String({ description: "API path, e.g. /api/config/namespaces/{namespace}/http_loadbalancers" }),
|
|
15
|
+
paths: Type.Optional(
|
|
16
|
+
Type.Array(Type.String(), {
|
|
17
|
+
description:
|
|
18
|
+
"Batch concurrent GETs: provide multiple API paths to execute in parallel. " +
|
|
19
|
+
"Returns combined results keyed by resource type. Ideal for namespace inventory queries. " +
|
|
20
|
+
"Overrides single `path` when non-empty. Only supported for GET method.",
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
14
23
|
params: Type.Optional(
|
|
15
24
|
Type.Record(Type.String(), Type.String(), {
|
|
16
25
|
description:
|
|
@@ -42,6 +51,12 @@ export interface XcshApiToolDetails {
|
|
|
42
51
|
errorCodeLabel?: string;
|
|
43
52
|
/** Whether the request was automatically retried after a transient error (429/503). */
|
|
44
53
|
retried?: boolean;
|
|
54
|
+
/** Batch mode: total paths executed concurrently. */
|
|
55
|
+
batchSize?: number;
|
|
56
|
+
/** Batch mode: paths that returned 2xx. */
|
|
57
|
+
batchSuccessCount?: number;
|
|
58
|
+
/** Batch mode: total items across all list responses. */
|
|
59
|
+
batchTotalItems?: number;
|
|
45
60
|
/** The resolved JSON body string sent to the API (after $F5XC_* expansion). */
|
|
46
61
|
resolvedPayload?: string;
|
|
47
62
|
}
|
|
@@ -68,6 +83,8 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
68
83
|
readonly parameters = xcshApiSchema;
|
|
69
84
|
#contextEnv: ContextEnv;
|
|
70
85
|
#lastApiBase = "";
|
|
86
|
+
#listablePathsCache: string[] | null = null;
|
|
87
|
+
#autoExpandDone = false;
|
|
71
88
|
|
|
72
89
|
constructor(session: ToolSession) {
|
|
73
90
|
this.description = prompt.render(xcshApiDescription);
|
|
@@ -97,6 +114,305 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
97
114
|
return { content: [{ type: "text", text }], details, isError: true };
|
|
98
115
|
}
|
|
99
116
|
|
|
117
|
+
/** Lazily load namespace-scoped list operation paths from the embedded API catalog. */
|
|
118
|
+
#loadListablePaths(): string[] {
|
|
119
|
+
if (this.#listablePathsCache) return this.#listablePathsCache;
|
|
120
|
+
try {
|
|
121
|
+
const mod = require("../internal-urls/api-catalog-index.generated") as {
|
|
122
|
+
API_CATALOG_CATEGORY_SUMMARIES?: ReadonlyArray<{ name: string }>;
|
|
123
|
+
API_CATALOG_DATA?: Readonly<
|
|
124
|
+
Record<string, { operations: ReadonlyArray<{ method: string; path: string }> }>
|
|
125
|
+
>;
|
|
126
|
+
};
|
|
127
|
+
const summaries = mod.API_CATALOG_CATEGORY_SUMMARIES ?? [];
|
|
128
|
+
const data = mod.API_CATALOG_DATA ?? {};
|
|
129
|
+
const seen = new Set<string>();
|
|
130
|
+
const paths: string[] = [];
|
|
131
|
+
const CONFIG_PREFIX = "/api/config/namespaces/{namespace}/";
|
|
132
|
+
// Only include app/security types (keyword filter). Reduces batch from ~136 to ~42 paths,
|
|
133
|
+
// cutting expansion time by ~3× and eliminating infrastructure noise from the response.
|
|
134
|
+
const APP_KW =
|
|
135
|
+
/loadbalancer|pool|firewall|_policys|setting|type|mitigation|identification|network|route|host|definition|rate_limiter|prefix_set|cdn|waf|api_/i;
|
|
136
|
+
const META_EXCL = /policy_set|policy_rule|data_polic/i;
|
|
137
|
+
for (const summary of summaries) {
|
|
138
|
+
const cat = data[summary.name];
|
|
139
|
+
if (!cat) continue;
|
|
140
|
+
for (const op of cat.operations) {
|
|
141
|
+
if (op.method.toUpperCase() !== "GET") continue;
|
|
142
|
+
if (!op.path.startsWith(CONFIG_PREFIX)) continue;
|
|
143
|
+
const segments = op.path.split("/").filter(Boolean);
|
|
144
|
+
if (segments.length !== 5) continue;
|
|
145
|
+
const last = segments.at(-1) ?? "";
|
|
146
|
+
if (last.startsWith("{")) continue;
|
|
147
|
+
if (!APP_KW.test(last) || META_EXCL.test(last)) continue;
|
|
148
|
+
if (!seen.has(op.path)) {
|
|
149
|
+
seen.add(op.path);
|
|
150
|
+
paths.push(op.path);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.#listablePathsCache = paths;
|
|
155
|
+
return paths;
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async #executeBatch(
|
|
162
|
+
paths: string[],
|
|
163
|
+
params: Record<string, string> | undefined,
|
|
164
|
+
apiBase: string,
|
|
165
|
+
apiToken: string,
|
|
166
|
+
signal?: AbortSignal,
|
|
167
|
+
): Promise<XcshApiResult> {
|
|
168
|
+
const requestId = crypto.randomUUID();
|
|
169
|
+
const contextName = this.#contextEnv.getContextName();
|
|
170
|
+
|
|
171
|
+
// File-based cache: reuse batch results across xcsh invocations (5-minute TTL).
|
|
172
|
+
// Prevents cumulative rate limiting when the benchmark runs multiple queries.
|
|
173
|
+
const ns = params?.namespace ?? this.#contextEnv.get("F5XC_NAMESPACE") ?? "_default";
|
|
174
|
+
const cachePath = `${os.tmpdir()}/xcsh-batch-${ns}.json`;
|
|
175
|
+
try {
|
|
176
|
+
const cached = (await Bun.file(cachePath).json()) as {
|
|
177
|
+
ts: number;
|
|
178
|
+
text: string;
|
|
179
|
+
batchSize: number;
|
|
180
|
+
batchSuccessCount: number;
|
|
181
|
+
batchTotalItems: number;
|
|
182
|
+
};
|
|
183
|
+
if (cached.ts > Date.now() - 600_000) {
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: "text", text: cached.text }],
|
|
186
|
+
details: {
|
|
187
|
+
status: 200,
|
|
188
|
+
url: apiBase,
|
|
189
|
+
method: "GET",
|
|
190
|
+
requestId,
|
|
191
|
+
durationMs: 0,
|
|
192
|
+
contextName,
|
|
193
|
+
batchSize: cached.batchSize,
|
|
194
|
+
batchSuccessCount: cached.batchSuccessCount,
|
|
195
|
+
batchTotalItems: cached.batchTotalItems,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Cache miss or invalid — proceed with fresh batch
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const headers: Record<string, string> = {
|
|
204
|
+
Authorization: `APIToken ${apiToken}`,
|
|
205
|
+
Accept: "application/json",
|
|
206
|
+
};
|
|
207
|
+
const timeoutSignal = AbortSignal.timeout(90_000);
|
|
208
|
+
const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
209
|
+
const startMs = performance.now();
|
|
210
|
+
|
|
211
|
+
type BatchEntry = {
|
|
212
|
+
path: string;
|
|
213
|
+
status: number;
|
|
214
|
+
statusText: string;
|
|
215
|
+
rawBody: string;
|
|
216
|
+
parsed: Record<string, unknown> | null;
|
|
217
|
+
itemCount: number | undefined;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const fetchOne = async (rawPath: string): Promise<BatchEntry> => {
|
|
221
|
+
const resolvedPath = this.#contextEnv.resolvePath(rawPath, params);
|
|
222
|
+
const url = `${apiBase}${resolvedPath}`;
|
|
223
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
const response = await fetch(url, { method: "GET", headers, signal: fetchSignal });
|
|
226
|
+
// Retry on 429/503 (transient rate limit / server error)
|
|
227
|
+
if ((response.status === 429 || response.status === 503) && attempt < 2 && !fetchSignal.aborted) {
|
|
228
|
+
await Bun.sleep(1000 * (attempt + 1));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const raw = await response.text();
|
|
232
|
+
let parsed: Record<string, unknown> | null = null;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
235
|
+
} catch {
|
|
236
|
+
// Non-JSON body
|
|
237
|
+
}
|
|
238
|
+
const items = parsed?.items;
|
|
239
|
+
const itemCount = Array.isArray(items) ? (items as unknown[]).length : undefined;
|
|
240
|
+
return {
|
|
241
|
+
path: rawPath,
|
|
242
|
+
status: response.status,
|
|
243
|
+
statusText: response.statusText,
|
|
244
|
+
rawBody: raw,
|
|
245
|
+
parsed,
|
|
246
|
+
itemCount,
|
|
247
|
+
};
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (attempt < 2 && !fetchSignal.aborted) {
|
|
250
|
+
await Bun.sleep(1000 * (attempt + 1));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
254
|
+
return {
|
|
255
|
+
path: rawPath,
|
|
256
|
+
status: 0,
|
|
257
|
+
statusText: "Error",
|
|
258
|
+
rawBody: message,
|
|
259
|
+
parsed: null,
|
|
260
|
+
itemCount: undefined,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Should not reach here but TypeScript needs a return
|
|
265
|
+
return {
|
|
266
|
+
path: rawPath,
|
|
267
|
+
status: 0,
|
|
268
|
+
statusText: "Error",
|
|
269
|
+
rawBody: "Max retries",
|
|
270
|
+
parsed: null,
|
|
271
|
+
itemCount: undefined,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// With file-based cache, only the first invocation hits the API; concurrency can be higher
|
|
276
|
+
const CONCURRENCY = 10;
|
|
277
|
+
const results: BatchEntry[] = [];
|
|
278
|
+
for (let i = 0; i < paths.length; i += CONCURRENCY) {
|
|
279
|
+
const chunk = paths.slice(i, i + CONCURRENCY);
|
|
280
|
+
const chunkResults = await Promise.all(chunk.map(fetchOne));
|
|
281
|
+
results.push(...chunkResults);
|
|
282
|
+
if (i + CONCURRENCY < paths.length && !fetchSignal.aborted) {
|
|
283
|
+
await Bun.sleep(200);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const durationMs = Math.round(performance.now() - startMs);
|
|
288
|
+
|
|
289
|
+
const withData = results.filter(r => (r.itemCount ?? 0) > 0);
|
|
290
|
+
// Batch already filtered to app/security types by #loadListablePaths().
|
|
291
|
+
// Still filter bulk types (>25 items) as a safety net.
|
|
292
|
+
const relevantData = withData.filter(r => (r.itemCount ?? 0) <= 25);
|
|
293
|
+
|
|
294
|
+
// Phase 2: fetch individual resource specs for items in non-empty types.
|
|
295
|
+
// 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>();
|
|
298
|
+
const specItems: Array<{ typePath: string; name: string }> = [];
|
|
299
|
+
for (const r of relevantData) {
|
|
300
|
+
const items = (r.parsed?.items as Array<Record<string, unknown>> | undefined) ?? [];
|
|
301
|
+
for (const item of items) {
|
|
302
|
+
const name = typeof item.name === "string" ? item.name : null;
|
|
303
|
+
if (name && items.length <= 10) specItems.push({ typePath: r.path, name });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (specItems.length > 0 && specItems.length <= 20 && !fetchSignal.aborted) {
|
|
307
|
+
for (let i = 0; i < specItems.length; i += CONCURRENCY) {
|
|
308
|
+
const chunk = specItems.slice(i, i + CONCURRENCY);
|
|
309
|
+
await Promise.all(
|
|
310
|
+
chunk.map(async ({ typePath, name }) => {
|
|
311
|
+
const specPath = this.#contextEnv.resolvePath(`${typePath}/${name}`, params);
|
|
312
|
+
try {
|
|
313
|
+
const resp = await fetch(`${apiBase}${specPath}`, { method: "GET", headers, signal: fetchSignal });
|
|
314
|
+
if (resp.ok) {
|
|
315
|
+
const data = (await resp.json()) as Record<string, unknown>;
|
|
316
|
+
const spec = data.spec as Record<string, unknown> | undefined;
|
|
317
|
+
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);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// Non-fatal
|
|
333
|
+
}
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
if (i + CONCURRENCY < specItems.length && !fetchSignal.aborted) await Bun.sleep(200);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Compact response: names only for discovery, no full JSON blobs
|
|
341
|
+
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;
|
|
348
|
+
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`);
|
|
361
|
+
let idx = 1;
|
|
362
|
+
for (const r of appTypes) {
|
|
363
|
+
const typeName = getTypeName(r);
|
|
364
|
+
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}`);
|
|
375
|
+
idx++;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (infraTypes.length > 0) {
|
|
380
|
+
sections.push(`\n(+${infraTypes.length} infrastructure/policy-meta types omitted)`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
sections.push("\nInventory complete. Report every numbered item above. No further API calls needed.");
|
|
384
|
+
const batchTotalItems = relevantData.reduce((sum, r) => sum + (r.itemCount ?? 0), 0);
|
|
385
|
+
const text = sections.join("\n");
|
|
386
|
+
const batchSize = paths.length;
|
|
387
|
+
const errorCount = results.filter(r => r.status >= 400 || r.status === 0).length;
|
|
388
|
+
const batchSuccessCount = results.length - errorCount;
|
|
389
|
+
|
|
390
|
+
// Cache for subsequent invocations
|
|
391
|
+
try {
|
|
392
|
+
await Bun.write(
|
|
393
|
+
cachePath,
|
|
394
|
+
JSON.stringify({ ts: Date.now(), text, batchSize, batchSuccessCount, batchTotalItems }),
|
|
395
|
+
);
|
|
396
|
+
} catch {
|
|
397
|
+
// Cache write failure is non-fatal
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
content: [{ type: "text", text }],
|
|
402
|
+
details: {
|
|
403
|
+
status: 200,
|
|
404
|
+
url: apiBase,
|
|
405
|
+
method: "GET",
|
|
406
|
+
requestId,
|
|
407
|
+
durationMs,
|
|
408
|
+
contextName,
|
|
409
|
+
batchSize,
|
|
410
|
+
batchSuccessCount,
|
|
411
|
+
batchTotalItems,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
100
416
|
#statusGuidance(status: number): string | null {
|
|
101
417
|
const ctx = this.#contextEnv.getContextName();
|
|
102
418
|
const ctxHint = ctx ? ` (context: \`${ctx}\`)` : "";
|
|
@@ -136,6 +452,37 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
136
452
|
`Error: No API token configured.${ctxNote} Activate a context with \`/context activate <name>\` or \`/context create\`, or set the F5XC_API_TOKEN environment variable.`,
|
|
137
453
|
);
|
|
138
454
|
}
|
|
455
|
+
const batchPaths = params.paths?.filter(p => p.trim().length > 0);
|
|
456
|
+
if (batchPaths && batchPaths.length > 0) {
|
|
457
|
+
// Wildcard "*" auto-discovers all namespace-scoped list paths from the catalog
|
|
458
|
+
const resolved = batchPaths.length === 1 && batchPaths[0] === "*" ? this.#loadListablePaths() : batchPaths;
|
|
459
|
+
if (resolved.length > 0) {
|
|
460
|
+
return this.#executeBatch(resolved, params.params, apiBase, apiToken, signal);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
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.
|
|
466
|
+
// File-based cache in #executeBatch prevents redundant API calls across sessions.
|
|
467
|
+
if (!this.#autoExpandDone && params.method === "GET" && !params.payload) {
|
|
468
|
+
const listablePaths = this.#loadListablePaths();
|
|
469
|
+
if (listablePaths.length > 0) {
|
|
470
|
+
// Normalize: replace actual namespace in path with {namespace} for matching
|
|
471
|
+
const normalized = params.path.replace(
|
|
472
|
+
/\/api\/config\/namespaces\/[^/]+\//,
|
|
473
|
+
"/api/config/namespaces/{namespace}/",
|
|
474
|
+
);
|
|
475
|
+
if (listablePaths.includes(params.path) || listablePaths.includes(normalized)) {
|
|
476
|
+
// Extract namespace from resolved path if params don't already have it
|
|
477
|
+
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);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
139
486
|
const resolvedPath = this.#contextEnv.resolvePath(params.path, params.params);
|
|
140
487
|
const unresolvedPlaceholders = resolvedPath.match(/\{\w+\}/g);
|
|
141
488
|
if (unresolvedPlaceholders) {
|