@f5xc-salesdemos/xcsh 18.58.1 → 18.59.1

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.58.1",
4
+ "version": "18.59.1",
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.58.1",
52
- "@f5xc-salesdemos/pi-agent-core": "18.58.1",
53
- "@f5xc-salesdemos/pi-ai": "18.58.1",
54
- "@f5xc-salesdemos/pi-natives": "18.58.1",
55
- "@f5xc-salesdemos/pi-tui": "18.58.1",
56
- "@f5xc-salesdemos/pi-utils": "18.58.1",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.59.1",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.59.1",
53
+ "@f5xc-salesdemos/pi-ai": "18.59.1",
54
+ "@f5xc-salesdemos/pi-natives": "18.59.1",
55
+ "@f5xc-salesdemos/pi-tui": "18.59.1",
56
+ "@f5xc-salesdemos/pi-utils": "18.59.1",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -372,7 +372,7 @@ export function createLogExperimentTool(
372
372
  },
373
373
  renderResult(result, _options, theme): Component {
374
374
  const details = result.details;
375
- if (!details) {
375
+ if (!isLogDetails(details)) {
376
376
  return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0);
377
377
  }
378
378
  return {
@@ -770,8 +770,16 @@ function truncateAsiValue(value: ASIData[string]): string {
770
770
  return text.length > 120 ? `${text.slice(0, 117)}...` : text;
771
771
  }
772
772
 
773
+ function isLogDetails(value: unknown): value is LogDetails {
774
+ if (typeof value !== "object" || value === null) return false;
775
+ return "experiment" in value && "state" in value;
776
+ }
777
+
773
778
  function renderSummary(details: LogDetails, theme: Theme, width?: number): string {
774
779
  const { experiment, state } = details;
780
+ if (!experiment || !state) {
781
+ return theme.fg("dim", "(no experiment data)");
782
+ }
775
783
  const color = experiment.status === "keep" ? "success" : experiment.status === "discard" ? "warning" : "error";
776
784
  let summary = `${theme.fg(color, experiment.status.toUpperCase())} ${theme.fg("muted", truncateToWidth(replaceTabs(experiment.description ?? ""), Math.max(20, (width ?? 100) - 30)))}`;
777
785
  summary += ` ${theme.fg("contentAccent", `${state.metricName}=${formatNum(experiment.metric, state.metricUnit)}`)}`;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.58.1",
21
- "commit": "771846c0cfc096fabe25f46e54af5ad4660a650d",
22
- "shortCommit": "771846c",
20
+ "version": "18.59.1",
21
+ "commit": "fa007898365b1a63e38748e06d2c86c13779ff53",
22
+ "shortCommit": "fa00789",
23
23
  "branch": "main",
24
- "tag": "v18.58.1",
25
- "commitDate": "2026-05-10T05:27:47Z",
26
- "buildDate": "2026-05-10T05:46:22.190Z",
24
+ "tag": "v18.59.1",
25
+ "commitDate": "2026-05-10T17:48:40Z",
26
+ "buildDate": "2026-05-10T18:15:56.886Z",
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/771846c0cfc096fabe25f46e54af5ad4660a650d",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.58.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/fa007898365b1a63e38748e06d2c86c13779ff53",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.59.1"
33
33
  };
@@ -14,4 +14,12 @@ Payload values like `$F5XC_NAMESPACE` are auto-expanded from the active context.
14
14
 
15
15
  Use this tool after reading the API catalog to get the endpoint path and payload structure.
16
16
 
17
+ Response format:
18
+ - **List**: `{"items": […], "errors": []}` — each item has `name`, `namespace`, `uid`.
19
+ - **Single resource**: `{"metadata": {"name", "namespace"}, "system_metadata": {"uid", "creation_timestamp"}, "spec": {…}}` — noise-reduced in TUI (nulls/empties stripped).
20
+ - **Create/Update**: Returns the full resource object. TUI shows a Created/Updated summary with name, uid, timestamp.
21
+ - **Delete**: Returns `{}`. TUI shows contextual confirmation.
22
+ - **Error**: `{"code": <int>, "message": "…"}` — codes: 3=INVALID_ARGUMENT, 5=NOT_FOUND, 6=ALREADY_EXISTS, 7=PERMISSION_DENIED, 13=INTERNAL.
23
+
24
+ GET requests auto-retry once on transient errors (429/503) after 1s backoff. POST/PUT/DELETE are never retried.
17
25
  API calls to the same F5 XC tenant reuse a single TLS connection — sequential calls are faster than parallel calls. Do not issue multiple xcsh_api calls in the same turn; issue them one at a time.
@@ -2,16 +2,19 @@
2
2
  * TUI renderer for the xcsh_api tool.
3
3
  *
4
4
  * Provides rich, context-aware visualization for F5 XC API calls:
5
- * - renderCall: method badge + path while request is pending
6
- * - renderResult: bordered output block with syntax-highlighted JSON body,
7
- * request details section, and error guidance section
5
+ * - renderCall: method badge + compact path while request is pending
6
+ * - renderResult: bordered output with intelligent response rendering:
7
+ * - List responses: compact resource name summary (capped at 20 items)
8
+ * - Single resources: Summary section (name, uid, created, status) + noise-reduced JSON
9
+ * - Create/Update: Created/Updated confirmation with key identity fields
10
+ * - Delete: contextual success message with resource name
11
+ * - Errors: API error message promoted to Guidance, JSON body suppressed
12
+ * - Request payload section for mutating methods (POST/PUT/PATCH)
8
13
  *
9
- * Uses CachedOutputBlock for bordered rendering with state-colored borders
10
- * (successdim, errorred, pendingaccent). JSON responses are
11
- * syntax-highlighted via the native pi-natives highlighter with theme colors.
12
- *
13
- * Always renders full output — no collapsed mode. This is the primary tool
14
- * for F5 XC platform operations and benefits from full visibility.
14
+ * Header meta: context name, colored duration, item count, body size, error code label.
15
+ * Borders: success=dim, error=red, pending=accent.
16
+ * JSON noise reduction via stripEmpty (nulls, empty strings, empty arrays stripped;
17
+ * empty objects preserved for F5 XC protobuf oneof markers).
15
18
  */
16
19
  import type { Component } from "@f5xc-salesdemos/pi-tui";
17
20
  import { Text } from "@f5xc-salesdemos/pi-tui";
@@ -24,6 +27,12 @@ import type { XcshApiToolDetails } from "./xcsh-api";
24
27
 
25
28
  const TOOL_TITLE = "XC-API";
26
29
 
30
+ /** Maximum response body lines before truncation. */
31
+ const MAX_RESPONSE_LINES = 80;
32
+
33
+ /** Maximum request payload lines before truncation. */
34
+ const MAX_PAYLOAD_LINES = 30;
35
+
27
36
  interface XcshApiRenderArgs {
28
37
  method?: string;
29
38
  path?: string;
@@ -50,6 +59,108 @@ function statusColor(status: number): ThemeColor {
50
59
  return "error";
51
60
  }
52
61
 
62
+ /** Format byte size to human-readable string (e.g. "1.2 KB", "3.4 MB"). */
63
+ function formatBytes(bytes: number): string {
64
+ if (bytes < 1024) return `${bytes} B`;
65
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
66
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
67
+ }
68
+
69
+ /**
70
+ * Strip null, empty string, and empty array fields recursively.
71
+ * Reduces JSON noise from F5 XC API responses which contain many null/empty defaults.
72
+ * Preserves empty objects `{}` — these are F5 XC protobuf oneof presence markers
73
+ * (e.g. `use_origin_server_name: {}` means that option is selected).
74
+ */
75
+ function stripEmpty(obj: unknown): unknown {
76
+ if (Array.isArray(obj)) return obj.map(stripEmpty).filter(v => v != null);
77
+ if (obj && typeof obj === "object") {
78
+ const entries = Object.entries(obj as Record<string, unknown>);
79
+ // Preserve source-empty objects (F5 XC oneof presence markers)
80
+ if (entries.length === 0) return obj;
81
+ const out: Record<string, unknown> = {};
82
+ for (const [k, v] of entries) {
83
+ if (v == null || v === "") continue;
84
+ if (Array.isArray(v) && v.length === 0) continue;
85
+ const cleaned = stripEmpty(v);
86
+ if (cleaned != null) out[k] = cleaned;
87
+ }
88
+ return Object.keys(out).length > 0 ? out : null;
89
+ }
90
+ return obj;
91
+ }
92
+
93
+ /** Format ISO timestamp to human-readable: `2026-05-10T00:02:42.577Z` → `2026-05-10 00:02 UTC`. */
94
+ function formatTimestamp(iso: string): string {
95
+ return iso
96
+ .replace("T", " ")
97
+ .replace(/:\d{2}\.\d+Z$/, " UTC")
98
+ .replace(/:\d{2}Z$/, " UTC");
99
+ }
100
+
101
+ /** Push a labeled section with optional line truncation. */
102
+ function pushSection(
103
+ sections: Array<{ label?: string; lines: string[] }>,
104
+ label: string,
105
+ lines: string[],
106
+ maxLines: number,
107
+ uiTheme: Theme,
108
+ ): void {
109
+ if (lines.length > maxLines) {
110
+ const truncated = lines.slice(0, maxLines);
111
+ truncated.push(uiTheme.fg("dim", `… ${lines.length - maxLines} more lines`));
112
+ sections.push({ label, lines: truncated });
113
+ } else {
114
+ sections.push({ label, lines });
115
+ }
116
+ }
117
+
118
+ /** Build a compact summary section for a single F5 XC resource (identity + spec). */
119
+ function buildResourceSummary(
120
+ parsed: Record<string, unknown>,
121
+ pathParts: string[],
122
+ method: string,
123
+ uiTheme: Theme,
124
+ ): { label: string; lines: string[] } | null {
125
+ const metadata = parsed.metadata as Record<string, unknown> | undefined;
126
+ const sysMeta = parsed.system_metadata as Record<string, unknown> | undefined;
127
+ if (!metadata || typeof metadata.name !== "string") return null;
128
+
129
+ const lines: string[] = [];
130
+ lines.push(uiTheme.fg("toolOutput", ` name: ${metadata.name}`));
131
+ if (typeof metadata.namespace === "string") lines.push(uiTheme.fg("dim", ` namespace: ${metadata.namespace}`));
132
+ if (typeof sysMeta?.uid === "string") lines.push(uiTheme.fg("dim", ` uid: ${sysMeta.uid}`));
133
+ const createdAt = sysMeta?.creation_timestamp;
134
+ if (typeof createdAt === "string") lines.push(uiTheme.fg("dim", ` created: ${formatTimestamp(createdAt)}`));
135
+ if (metadata.disable === true) lines.push(uiTheme.fg("warning", ` status: DISABLED`));
136
+
137
+ // Compact spec line: resource type from path + key config values
138
+ const spec = parsed.spec;
139
+ if (spec && typeof spec === "object") {
140
+ const specEntries = Object.entries(spec as Record<string, unknown>);
141
+ // Only show spec line when there are actual entries (skip empty spec: {})
142
+ if (specEntries.length > 0) {
143
+ const specScalars = specEntries
144
+ .filter(
145
+ ([, v]) =>
146
+ typeof v === "number" ||
147
+ typeof v === "boolean" ||
148
+ (typeof v === "string" && v.length > 0 && v.length <= 30),
149
+ )
150
+ .slice(0, 4)
151
+ .map(([k, v]) => `${k}=${v}`)
152
+ .join(", ");
153
+ const resourceType = (pathParts.at(-2) ?? "config").replace(/_/g, " ").replace(/s$/, "");
154
+ const specLine = specScalars ? `${resourceType} (${specScalars})` : resourceType;
155
+ lines.push(uiTheme.fg("dim", ` spec: ${specLine}`));
156
+ }
157
+ }
158
+
159
+ const isMutating = method === "POST" || method === "PUT" || method === "PATCH";
160
+ const label = isMutating ? (method === "POST" ? "Created" : "Updated") : "Summary";
161
+ return { label: uiTheme.fg("toolTitle", label), lines };
162
+ }
163
+
53
164
  /**
54
165
  * Split the text content from the tool result into its constituent parts.
55
166
  *
@@ -103,11 +214,14 @@ export const xcshApiToolRenderer = {
103
214
  renderCall(args: XcshApiRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
104
215
  const method = args.method ?? "???";
105
216
  const apiPath = args.path ?? "…";
217
+ // Compact long paths for pending state consistency with result header
218
+ const parts = apiPath.split("/").filter(Boolean);
219
+ const pendingPath = parts.length > 3 ? `…/${parts.slice(-3).join("/")}` : apiPath;
106
220
  const text = renderStatusLine(
107
221
  {
108
222
  icon: "pending",
109
223
  title: TOOL_TITLE,
110
- description: apiPath,
224
+ description: pendingPath,
111
225
  badge: { label: method, color: methodColor(method) },
112
226
  },
113
227
  uiTheme,
@@ -135,6 +249,9 @@ export const xcshApiToolRenderer = {
135
249
  // Malformed URL — fall through to args.path
136
250
  }
137
251
  }
252
+ // Path intelligence: extract resource name and compact display path
253
+ const pathParts = displayPath.split("/").filter(Boolean);
254
+ const resourceName = pathParts.at(-1) ?? "";
138
255
 
139
256
  const status = details?.status ?? 0;
140
257
  const statusText = status > 0 ? `${status}` : "failed";
@@ -145,16 +262,36 @@ export const xcshApiToolRenderer = {
145
262
  return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
146
263
  }
147
264
 
148
- // --- Header ---
265
+ // --- Header with separate method and status badges ---
266
+ const methodBadge = { label: method, color: methodColor(method) };
267
+ const errorLabel = details?.errorCodeLabel;
268
+ const statusDisplay = errorLabel ? `${statusText} ${errorLabel}` : statusText;
269
+ const statusBadge = uiTheme.fg(status > 0 ? statusColor(status) : "error", `[${statusDisplay}]`);
270
+
149
271
  const meta: string[] = [];
272
+ meta.push(statusBadge);
150
273
  if (details?.contextName) meta.push(uiTheme.fg("statusLineContextF5xcFg", details.contextName));
151
- if (details?.durationMs !== undefined) meta.push(uiTheme.fg("dim", `${details.durationMs}ms`));
274
+ if (details?.durationMs !== undefined) {
275
+ const durationColor: ThemeColor =
276
+ details.durationMs < 200 ? "success" : details.durationMs > 1000 ? "warning" : "dim";
277
+ meta.push(uiTheme.fg(durationColor, `${details.durationMs}ms`));
278
+ }
279
+ if (details?.itemCount !== undefined) meta.push(uiTheme.fg("dim", `${details.itemCount} items`));
280
+ if (details?.bodySize !== undefined) meta.push(uiTheme.fg("dim", formatBytes(details.bodySize)));
281
+ if (details?.contentType && !details.contentType.includes("json"))
282
+ meta.push(uiTheme.fg("dim", details.contentType));
283
+ // Show requestId for errors (useful for support/debugging)
284
+ if (isError && details?.requestId) meta.push(uiTheme.fg("dim", `req:${details.requestId.slice(0, 8)}`));
285
+ if (details?.retried) meta.push(uiTheme.fg("warning", "retried"));
286
+
287
+ const compactPath = pathParts.length > 3 ? `…/${pathParts.slice(-3).join("/")}` : displayPath;
288
+
152
289
  const header = renderStatusLine(
153
290
  {
154
291
  title: TOOL_TITLE,
155
292
  titleColor: "contentAccent",
156
- description: displayPath,
157
- badge: { label: `${method} ${statusText}`, color: status > 0 ? statusColor(status) : "error" },
293
+ description: compactPath,
294
+ badge: methodBadge,
158
295
  meta: meta.length > 0 ? meta : undefined,
159
296
  },
160
297
  uiTheme,
@@ -165,17 +302,119 @@ export const xcshApiToolRenderer = {
165
302
  const { json, guidance, raw } = splitResultContent(textContent, isError);
166
303
  const sections: Array<{ label?: string; lines: string[] }> = [];
167
304
 
168
- // Section 1: Request line (method + full resolved URL)
169
- const requestLine = url ? `${method} ${url}` : `${method} ${displayPath}`;
170
- sections.push({ lines: [uiTheme.fg("dim", requestLine)] });
305
+ // Section 2: Request payload (for mutating methods with a body)
306
+ if (args?.payload && method !== "GET") {
307
+ try {
308
+ const prettyPayload = JSON.stringify(args.payload, null, 2);
309
+ const payloadLines = highlightCode(prettyPayload, "json");
310
+ const sanitized = payloadLines.map(line => replaceTabs(line));
311
+ // Show expanded variable substitutions
312
+ if (details?.expandedVars && details.expandedVars.length > 0) {
313
+ const varLines = details.expandedVars.map(({ variable, value }) =>
314
+ uiTheme.fg("dim", ` ${variable} → ${value}`),
315
+ );
316
+ sanitized.push(...varLines);
317
+ }
318
+ pushSection(sections, uiTheme.fg("toolTitle", "Request"), sanitized, MAX_PAYLOAD_LINES, uiTheme);
319
+ } catch {
320
+ // Payload not serializable — skip section
321
+ }
322
+ }
323
+
324
+ // Section: Response body — syntax-highlighted JSON or plain text
325
+ // Parse JSON once for all intelligence branches
326
+ const emptyBody = json === "{}" || (!json && (!raw.trim() || raw.trim() === "{}"));
327
+ let parsed: Record<string, unknown> | null = null;
328
+ if (json && !emptyBody) {
329
+ try {
330
+ parsed = JSON.parse(json) as Record<string, unknown>;
331
+ } catch {
332
+ // Not parseable — render raw
333
+ }
334
+ }
335
+ const emptyList = Array.isArray(parsed?.items) && (parsed!.items as unknown[]).length === 0;
171
336
 
172
- // Section 2: Response body syntax-highlighted JSON or plain text
173
- if (json) {
174
- const highlighted = highlightCode(json, "json");
337
+ if ((emptyBody || emptyList) && !guidance) {
338
+ // Contextual success message based on HTTP method and response shape
339
+ let successMessage = emptyList ? "No items found." : "Empty response";
340
+ const rn = resourceName ? ` \u2018${resourceName}\u2019` : "";
341
+ if (!emptyList && status >= 200 && status < 300) {
342
+ if (method === "DELETE") successMessage = `Resource${rn} deleted successfully.`;
343
+ else if (method === "POST") successMessage = `Resource${rn} created successfully.`;
344
+ else if (method === "PUT" || method === "PATCH") successMessage = `Resource${rn} updated successfully.`;
345
+ }
175
346
  sections.push({
176
347
  label: uiTheme.fg("toolTitle", "Response"),
177
- lines: highlighted.map(line => replaceTabs(line)),
348
+ lines: [uiTheme.fg("dim", successMessage)],
178
349
  });
350
+ } else if (json && parsed) {
351
+ // Branch 1: List response with named items — compact summary
352
+ const items = parsed.items;
353
+ if (Array.isArray(items) && items.length > 0) {
354
+ const itemEntries = (items as Array<Record<string, unknown>>)
355
+ .map(item => {
356
+ const name = typeof item.name === "string" ? item.name : null;
357
+ return name ? { name, disabled: item.disabled === true } : null;
358
+ })
359
+ .filter(Boolean) as Array<{ name: string; disabled: boolean }>;
360
+ if (itemEntries.length > 0) {
361
+ const maxListItems = 20;
362
+ const displayed = itemEntries.slice(0, maxListItems);
363
+ const summaryLines = displayed.map(({ name, disabled }) =>
364
+ disabled
365
+ ? ` ${uiTheme.fg("dim", name)} ${uiTheme.fg("warning", "DISABLED")}`
366
+ : uiTheme.fg("toolOutput", ` ${name}`),
367
+ );
368
+ if (itemEntries.length > maxListItems)
369
+ summaryLines.push(uiTheme.fg("dim", ` … and ${itemEntries.length - maxListItems} more`));
370
+ pushSection(
371
+ sections,
372
+ uiTheme.fg("toolTitle", `Response (${itemEntries.length} items)`),
373
+ summaryLines,
374
+ maxListItems + 1,
375
+ uiTheme,
376
+ );
377
+ }
378
+ } else {
379
+ // Branch 2: Single resource with metadata — summary + noise-reduced JSON
380
+ const summary = !isError ? buildResourceSummary(parsed, pathParts, method, uiTheme) : null;
381
+ if (summary) sections.push(summary);
382
+ const isMutating = method === "POST" || method === "PUT" || method === "PATCH";
383
+
384
+ // Determine if JSON body should be suppressed
385
+ let apiErrorMessage: string | undefined;
386
+ if (isError && typeof parsed.message === "string" && parsed.message) apiErrorMessage = parsed.message;
387
+ const skipJsonBody = (summary && isMutating) || (isError && (guidance || apiErrorMessage));
388
+
389
+ // Show extracted API error for errors without statusGuidance (400, 422, etc.)
390
+ if (apiErrorMessage && !guidance) {
391
+ sections.push({
392
+ label: uiTheme.fg("toolTitle", "Error"),
393
+ lines: [uiTheme.fg("error", apiErrorMessage)],
394
+ });
395
+ }
396
+
397
+ if (!skipJsonBody) {
398
+ const displayJson = JSON.stringify(stripEmpty(parsed), null, 2) ?? json;
399
+ const jsonLines = displayJson.split("\n");
400
+ const highlighted = isError
401
+ ? jsonLines.map(line => uiTheme.fg("dim", replaceTabs(line)))
402
+ : highlightCode(displayJson, "json").map(line => replaceTabs(line));
403
+
404
+ let keyCount: number | undefined;
405
+ if (typeof parsed === "object" && !Array.isArray(parsed)) keyCount = Object.keys(parsed).length;
406
+ const responseLabel =
407
+ keyCount !== undefined && keyCount > 0 ? `Response (${keyCount} keys)` : "Response";
408
+
409
+ pushSection(sections, uiTheme.fg("toolTitle", responseLabel), highlighted, MAX_RESPONSE_LINES, uiTheme);
410
+ }
411
+ }
412
+ } else if (json) {
413
+ // Non-parseable JSON — render raw
414
+ const highlighted = isError
415
+ ? json.split("\n").map(line => uiTheme.fg("dim", replaceTabs(line)))
416
+ : highlightCode(json, "json").map(line => replaceTabs(line));
417
+ sections.push({ label: uiTheme.fg("toolTitle", "Response"), lines: highlighted });
179
418
  } else if (raw.trim() && !guidance) {
180
419
  // Non-JSON, non-guidance body
181
420
  sections.push({
@@ -187,12 +426,14 @@ export const xcshApiToolRenderer = {
187
426
  });
188
427
  }
189
428
 
190
- // Section 3: Error guidance (for HTTP error responses)
429
+ // Section 4: Error guidance (for HTTP error responses)
191
430
  if (guidance) {
192
- sections.push({
193
- label: uiTheme.fg("toolTitle", "Guidance"),
194
- lines: [uiTheme.fg("warning", guidance)],
195
- });
431
+ // Extract the API's specific error message from JSON body for prominent display
432
+ const guidanceLines: string[] = [];
433
+ const apiMessage = parsed && typeof parsed.message === "string" ? parsed.message : undefined;
434
+ if (apiMessage) guidanceLines.push(uiTheme.fg("error", apiMessage));
435
+ guidanceLines.push(uiTheme.fg("warning", guidance));
436
+ sections.push({ label: uiTheme.fg("toolTitle", "Guidance"), lines: guidanceLines });
196
437
  }
197
438
 
198
439
  // --- Render with CachedOutputBlock ---
@@ -32,10 +32,35 @@ export interface XcshApiToolDetails {
32
32
  durationMs?: number;
33
33
  /** Active context profile name, if available. */
34
34
  contextName?: string;
35
+ /** Response body size in bytes. */
36
+ bodySize?: number;
37
+ /** Number of items in the response `items` array (list operations). */
38
+ itemCount?: number;
39
+ /** Response content-type header. */
40
+ contentType?: string;
41
+ /** F5 XC gRPC error code label (e.g. NOT_FOUND, ALREADY_EXISTS) when present in response body. */
42
+ errorCodeLabel?: string;
43
+ /** Whether the request was automatically retried after a transient error (429/503). */
44
+ retried?: boolean;
45
+ /** Payload variables that were expanded (e.g. $F5XC_NAMESPACE → r-mordasiewicz). */
46
+ expandedVars?: Array<{ variable: string; value: string }>;
35
47
  }
36
48
 
37
49
  type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
38
50
 
51
+ /** F5 XC gRPC error code labels for human-readable error display. */
52
+ const F5XC_ERROR_CODES: Record<number, string> = {
53
+ 3: "INVALID_ARGUMENT",
54
+ 5: "NOT_FOUND",
55
+ 6: "ALREADY_EXISTS",
56
+ 7: "PERMISSION_DENIED",
57
+ 8: "RESOURCE_EXHAUSTED",
58
+ 9: "FAILED_PRECONDITION",
59
+ 13: "INTERNAL",
60
+ 14: "UNAVAILABLE",
61
+ 16: "UNAUTHENTICATED",
62
+ };
63
+
39
64
  export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolDetails> {
40
65
  readonly name = "xcsh_api";
41
66
  readonly label = "API";
@@ -93,13 +118,14 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
93
118
  return `Access denied${ctxHint}. The API token may lack the required role or permission for this operation. Check the token's role assignments in the F5 XC console.`;
94
119
  case 404: {
95
120
  const ns = process.env.F5XC_NAMESPACE ?? this.#contextEnv.get("F5XC_NAMESPACE") ?? "default";
96
- return `Resource not found. Verify the resource name exists in namespace \`${ns}\`${ctxHint}. Use a GET list operation to check existing resources.`;
121
+ return `Resource not found in namespace \`${ns}\`${ctxHint}. Use a GET list operation to verify existing resources.`;
97
122
  }
98
123
  case 409:
99
124
  return `Resource already exists${ctxHint}. Use PUT to replace the existing resource, or DELETE it first before creating a new one.`;
100
125
  case 429:
101
126
  return `API rate limit exceeded${ctxHint}. Wait briefly and retry the request.`;
102
127
  default:
128
+ if (status >= 500) return `Server error (${status})${ctxHint}. This may be transient — retry the request.`;
103
129
  return null;
104
130
  }
105
131
  }
@@ -153,15 +179,45 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
153
179
  signal: fetchSignal,
154
180
  };
155
181
 
182
+ let expandedVars: Array<{ variable: string; value: string }> | undefined;
156
183
  if (params.payload && params.method !== "GET") {
157
184
  headers["Content-Type"] = "application/json";
158
185
  const payloadJson = JSON.stringify(params.payload);
159
- init.body = this.#contextEnv.resolvePayloadVars(payloadJson);
186
+ const resolved = this.#contextEnv.resolvePayloadVars(payloadJson);
187
+ init.body = resolved;
188
+ // Track which $F5XC_* variables were expanded
189
+ if (resolved !== payloadJson) {
190
+ expandedVars = [];
191
+ const env = this.#contextEnv;
192
+ for (const match of payloadJson.matchAll(/\$F5XC_([A-Z0-9_]+)/g)) {
193
+ const key = `F5XC_${match[1]}`;
194
+ const value = env.get(key) ?? process.env[key];
195
+ if (value) expandedVars.push({ variable: `$${key}`, value });
196
+ }
197
+ }
160
198
  }
161
199
 
162
200
  const startMs = performance.now();
163
201
  try {
164
- const response = await fetch(url, init);
202
+ let response = await fetch(url, init);
203
+
204
+ // Auto-retry idempotent GET requests on transient errors (429/503)
205
+ let retried = false;
206
+ if (params.method === "GET" && (response.status === 429 || response.status === 503) && !fetchSignal.aborted) {
207
+ // Parse Retry-After header: seconds (integer) or HTTP-date
208
+ const retryAfter = response.headers.get("retry-after");
209
+ let delayMs = 1000;
210
+ if (retryAfter) {
211
+ const seconds = Number.parseInt(retryAfter, 10);
212
+ if (Number.isFinite(seconds) && seconds > 0) delayMs = Math.min(seconds * 1000, 10_000);
213
+ }
214
+ await Bun.sleep(delayMs);
215
+ if (!fetchSignal.aborted) {
216
+ response = await fetch(url, init);
217
+ retried = true;
218
+ }
219
+ }
220
+
165
221
  const raw = await response.text();
166
222
  const durationMs = Math.round(performance.now() - startMs);
167
223
  const contentType = response.headers.get("content-type") ?? "";
@@ -176,12 +232,41 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
176
232
  const statusLine = `${response.status} ${response.statusText}`;
177
233
 
178
234
  const contextName = this.#contextEnv.getContextName();
179
- const detail = { status: response.status, url, method: params.method, requestId, durationMs, contextName };
235
+ const bodySize = raw.length;
236
+ // Parse response JSON once for item count, error code label, etc.
237
+ let parsedBody: Record<string, unknown> | null = null;
238
+ let itemCount: number | undefined;
239
+ try {
240
+ parsedBody = JSON.parse(raw) as Record<string, unknown>;
241
+ if (Array.isArray(parsedBody?.items)) itemCount = (parsedBody.items as unknown[]).length;
242
+ } catch {
243
+ // Not JSON — skip structured extraction
244
+ }
245
+ const detail: XcshApiToolDetails = {
246
+ status: response.status,
247
+ url,
248
+ method: params.method,
249
+ requestId,
250
+ durationMs,
251
+ contextName,
252
+ bodySize,
253
+ itemCount,
254
+ contentType: contentType || undefined,
255
+ retried: retried || undefined,
256
+ expandedVars,
257
+ };
180
258
 
181
259
  // Context-aware CRUD error guidance for common HTTP status codes
182
260
  const guidance = this.#statusGuidance(response.status);
183
261
  if (guidance) {
184
- return this.#errorResult(`${statusLine}\n\n${bodyText}\n\n${guidance}`, detail);
262
+ // Enrich with F5 XC error code label when present in response body
263
+ let errorCodePrefix = "";
264
+ const codeLabel = parsedBody ? F5XC_ERROR_CODES[parsedBody.code as number] : undefined;
265
+ if (codeLabel) {
266
+ errorCodePrefix = `[${codeLabel}] `;
267
+ detail.errorCodeLabel = codeLabel;
268
+ }
269
+ return this.#errorResult(`${statusLine}\n\n${bodyText}\n\n${errorCodePrefix}${guidance}`, detail);
185
270
  }
186
271
 
187
272
  return {