@f5xc-salesdemos/xcsh 18.54.0 → 18.56.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 +7 -7
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/prompts/tools/xcsh-api.md +7 -1
- package/src/services/context-env.ts +24 -2
- package/src/services/f5xc-context.ts +4 -0
- package/src/services/f5xc-env.ts +2 -0
- package/src/session/messages.ts +57 -8
- package/src/tools/renderers.ts +2 -0
- package/src/tools/xcsh-api-renderer.ts +147 -0
- package/src/tools/xcsh-api.ts +103 -20
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.56.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.56.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "18.56.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "18.56.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "18.56.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "18.56.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "18.56.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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.56.0",
|
|
21
|
+
"commit": "d61f41711c0498bf98d02028b9b49159e3dcf415",
|
|
22
|
+
"shortCommit": "d61f417",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.56.0",
|
|
25
|
+
"commitDate": "2026-05-09T21:11:13Z",
|
|
26
|
+
"buildDate": "2026-05-09T21:32:38.103Z",
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/d61f41711c0498bf98d02028b9b49159e3dcf415",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.56.0"
|
|
33
33
|
};
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
Execute an F5 Distributed Cloud API call directly.
|
|
2
2
|
|
|
3
3
|
Handles authentication, URL construction, and HTTP execution.
|
|
4
|
-
|
|
4
|
+
Credentials are resolved from the active context profile (`/context`). Environment variables
|
|
5
|
+
`F5XC_API_URL` and `F5XC_API_TOKEN` override context values when set.
|
|
6
|
+
|
|
7
|
+
Path parameters like `{namespace}` are auto-resolved from the active context when not
|
|
8
|
+
explicitly provided in `params`. For example, `{namespace}` resolves to the context's
|
|
9
|
+
default namespace (`F5XC_NAMESPACE`).
|
|
5
10
|
|
|
6
11
|
Pass all path `{placeholder}` values via `params`, e.g. `{ namespace: "default", name: "example-lb", vh_name: "example-vh" }`.
|
|
7
12
|
Body is sent for all methods except GET when `payload` is provided — including DELETE operations that require a body.
|
|
13
|
+
Payload values like `$F5XC_NAMESPACE` are auto-expanded from the active context.
|
|
8
14
|
|
|
9
15
|
Use this tool after reading the API catalog to get the endpoint path and payload structure.
|
|
10
16
|
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import { SECRET_ENV_PATTERNS } from "../secrets/index";
|
|
2
|
-
import { F5XC_API_TOKEN, F5XC_API_URL, F5XC_NAMESPACE, F5XC_TENANT } from "./f5xc-env";
|
|
2
|
+
import { F5XC_API_TOKEN, F5XC_API_URL, F5XC_CONTEXT_NAME, F5XC_NAMESPACE, F5XC_TENANT } from "./f5xc-env";
|
|
3
3
|
|
|
4
4
|
/** Keys excluded from the system prompt context variables listing. */
|
|
5
|
-
const PROMPT_HIDDEN: ReadonlySet<string> = new Set([
|
|
5
|
+
const PROMPT_HIDDEN: ReadonlySet<string> = new Set([
|
|
6
|
+
F5XC_API_TOKEN,
|
|
7
|
+
F5XC_API_URL,
|
|
8
|
+
F5XC_TENANT,
|
|
9
|
+
F5XC_NAMESPACE,
|
|
10
|
+
F5XC_CONTEXT_NAME,
|
|
11
|
+
]);
|
|
6
12
|
|
|
7
13
|
/** Keys never expanded in payloads — credentials that must not leak into request bodies. */
|
|
8
14
|
const PAYLOAD_HIDDEN: ReadonlySet<string> = new Set([F5XC_API_TOKEN, F5XC_API_URL]);
|
|
9
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Bridge between F5 XC context profiles and the xcsh_api tool.
|
|
18
|
+
*
|
|
19
|
+
* Reads from `Settings.bash.environment` (populated by ContextService on profile
|
|
20
|
+
* activation) and provides credential resolution, path parameter auto-filling,
|
|
21
|
+
* and payload variable expansion. Consumed by:
|
|
22
|
+
* - `XcshApiTool` for credential and namespace resolution during API calls
|
|
23
|
+
* - `sdk.ts` for surfacing non-sensitive context vars in the system prompt
|
|
24
|
+
*/
|
|
10
25
|
export interface ContextEnv {
|
|
11
26
|
/** Get a single env var value from bash.environment, or undefined. */
|
|
12
27
|
get(key: string): string | undefined;
|
|
@@ -32,6 +47,9 @@ export interface ContextEnv {
|
|
|
32
47
|
* matching SECRET_ENV_PATTERNS, and explicitly provided sensitiveKeys.
|
|
33
48
|
*/
|
|
34
49
|
getNonSensitiveVars(): Record<string, string>;
|
|
50
|
+
|
|
51
|
+
/** Return the active context profile name, or undefined if not set. */
|
|
52
|
+
getContextName(): string | undefined;
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
export interface ContextEnvOptions {
|
|
@@ -63,6 +81,10 @@ export function createContextEnv(settings: { get(key: string): unknown }, option
|
|
|
63
81
|
return bashEnv()[key];
|
|
64
82
|
},
|
|
65
83
|
|
|
84
|
+
getContextName(): string | undefined {
|
|
85
|
+
return bashEnv()[F5XC_CONTEXT_NAME] || undefined;
|
|
86
|
+
},
|
|
87
|
+
|
|
66
88
|
resolvePath(path: string, explicitParams?: Record<string, string>): string {
|
|
67
89
|
let resolved = path;
|
|
68
90
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
deriveTenantFromUrl,
|
|
9
9
|
F5XC_API_TOKEN,
|
|
10
10
|
F5XC_API_URL,
|
|
11
|
+
F5XC_CONTEXT_NAME,
|
|
11
12
|
F5XC_NAMESPACE,
|
|
12
13
|
F5XC_TENANT,
|
|
13
14
|
hasEnvOverride,
|
|
@@ -1328,6 +1329,9 @@ export class ContextService {
|
|
|
1328
1329
|
if (tenant) merged[F5XC_TENANT] = tenant;
|
|
1329
1330
|
}
|
|
1330
1331
|
|
|
1332
|
+
// Inject context profile name for API tool identity surfacing
|
|
1333
|
+
merged[F5XC_CONTEXT_NAME] = context.name;
|
|
1334
|
+
|
|
1331
1335
|
// Inject all additional env vars from context.env map
|
|
1332
1336
|
if (context.env) {
|
|
1333
1337
|
for (const [key, value] of Object.entries(context.env)) {
|
package/src/services/f5xc-env.ts
CHANGED
|
@@ -9,6 +9,8 @@ export const F5XC_NAMESPACE = "F5XC_NAMESPACE" as const;
|
|
|
9
9
|
export const F5XC_TENANT = "F5XC_TENANT" as const;
|
|
10
10
|
export const F5XC_USERNAME = "F5XC_USERNAME" as const;
|
|
11
11
|
export const F5XC_CONSOLE_PASSWORD = "F5XC_CONSOLE_PASSWORD" as const;
|
|
12
|
+
/** Active context profile name. Read-only metadata injected by ContextService. */
|
|
13
|
+
export const F5XC_CONTEXT_NAME = "F5XC_CONTEXT_NAME" as const;
|
|
12
14
|
|
|
13
15
|
export const RESERVED_ENV_KEYS: ReadonlySet<string> = new Set([
|
|
14
16
|
F5XC_NAMESPACE,
|
package/src/session/messages.ts
CHANGED
|
@@ -292,14 +292,14 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
// Track which
|
|
296
|
-
const
|
|
295
|
+
// Track which message indices have been consumed (placed or displaced) by repair
|
|
296
|
+
const consumedIndices = new Set<number>();
|
|
297
297
|
|
|
298
298
|
for (let i = 0; i < messages.length; i++) {
|
|
299
299
|
const msg = messages[i];
|
|
300
300
|
|
|
301
|
-
// Skip toolResult messages that were already placed
|
|
302
|
-
if (msg.role === "toolResult" &&
|
|
301
|
+
// Skip toolResult messages that were already consumed (placed elsewhere or displaced)
|
|
302
|
+
if (msg.role === "toolResult" && consumedIndices.has(i)) {
|
|
303
303
|
continue;
|
|
304
304
|
}
|
|
305
305
|
|
|
@@ -326,7 +326,7 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
326
326
|
if (requiredIds.has(trMsg.toolCallId)) {
|
|
327
327
|
// This tool_result belongs here — place it
|
|
328
328
|
result.push(next);
|
|
329
|
-
|
|
329
|
+
consumedIndices.add(j);
|
|
330
330
|
requiredIds.delete(trMsg.toolCallId);
|
|
331
331
|
if (displaced.length > 0) repaired = true;
|
|
332
332
|
j++;
|
|
@@ -335,7 +335,7 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
335
335
|
}
|
|
336
336
|
// Non-matching message between tool_use and tool_result — displace it
|
|
337
337
|
displaced.push(next);
|
|
338
|
-
|
|
338
|
+
consumedIndices.add(j); // Mark original index as consumed
|
|
339
339
|
j++;
|
|
340
340
|
}
|
|
341
341
|
|
|
@@ -345,9 +345,9 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
345
345
|
// Any remaining required IDs: find them later in the array or synthesize
|
|
346
346
|
for (const id of requiredIds) {
|
|
347
347
|
const found = toolResultsByCallId.get(id);
|
|
348
|
-
if (found && !
|
|
348
|
+
if (found && !consumedIndices.has(found.originalIndex)) {
|
|
349
349
|
result.push(found.message);
|
|
350
|
-
|
|
350
|
+
consumedIndices.add(found.originalIndex);
|
|
351
351
|
repaired = true;
|
|
352
352
|
} else {
|
|
353
353
|
// Missing tool_result entirely — inject synthetic error result
|
|
@@ -370,6 +370,55 @@ function repairToolResultOrdering(messages: Message[]): Message[] {
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
// Second pass: repair displaced assistant messages whose tool calls were never processed.
|
|
374
|
+
// When an assistant-with-tool-calls gets displaced (wedged between another assistant's
|
|
375
|
+
// tool_use and its tool_result), the first pass pushes it to result but the outer loop
|
|
376
|
+
// jumps past it — so its own tool_results are never resolved.
|
|
377
|
+
for (let i = 0; i < result.length; i++) {
|
|
378
|
+
const msg = result[i];
|
|
379
|
+
if (msg.role !== "assistant") continue;
|
|
380
|
+
const assistantMsg = msg as AssistantMessage;
|
|
381
|
+
const toolCalls = assistantMsg.content.filter((c): c is ToolCall => c.type === "toolCall");
|
|
382
|
+
if (toolCalls.length === 0) continue;
|
|
383
|
+
|
|
384
|
+
// Check if every tool call has a toolResult immediately following
|
|
385
|
+
const expectedIds = new Set(toolCalls.map(tc => tc.id));
|
|
386
|
+
let j = i + 1;
|
|
387
|
+
while (j < result.length && result[j].role === "toolResult") {
|
|
388
|
+
expectedIds.delete((result[j] as ToolResultMessage).toolCallId);
|
|
389
|
+
j++;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (expectedIds.size === 0) continue;
|
|
393
|
+
|
|
394
|
+
// For missing tool calls: relocate existing result from later in array, or synthesize
|
|
395
|
+
const toInsert: ToolResultMessage[] = [];
|
|
396
|
+
for (const id of expectedIds) {
|
|
397
|
+
// Check if a toolResult for this ID exists later in result
|
|
398
|
+
const laterIndex = result.findIndex(
|
|
399
|
+
(m, idx) => idx > j && m.role === "toolResult" && (m as ToolResultMessage).toolCallId === id,
|
|
400
|
+
);
|
|
401
|
+
if (laterIndex !== -1) {
|
|
402
|
+
// Relocate the existing result to right after the assistant
|
|
403
|
+
const [relocated] = result.splice(laterIndex, 1);
|
|
404
|
+
toInsert.push(relocated as ToolResultMessage);
|
|
405
|
+
} else {
|
|
406
|
+
const toolCall = toolCalls.find(tc => tc.id === id);
|
|
407
|
+
toInsert.push({
|
|
408
|
+
role: "toolResult",
|
|
409
|
+
toolCallId: id,
|
|
410
|
+
toolName: toolCall?.name ?? "unknown",
|
|
411
|
+
content: [{ type: "text", text: "Tool execution was interrupted (session recovery)." }],
|
|
412
|
+
isError: true,
|
|
413
|
+
timestamp: Date.now(),
|
|
414
|
+
} as ToolResultMessage);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
result.splice(i + 1, 0, ...toInsert);
|
|
418
|
+
i += toInsert.length; // Skip past inserted results
|
|
419
|
+
repaired = true;
|
|
420
|
+
}
|
|
421
|
+
|
|
373
422
|
if (repaired) {
|
|
374
423
|
logger.warn("Repaired tool_use/tool_result ordering in conversation history");
|
|
375
424
|
}
|
package/src/tools/renderers.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { searchToolBm25Renderer } from "./search-tool-bm25";
|
|
|
27
27
|
import { sshToolRenderer } from "./ssh";
|
|
28
28
|
import { todoWriteToolRenderer } from "./todo-write";
|
|
29
29
|
import { writeToolRenderer } from "./write";
|
|
30
|
+
import { xcshApiToolRenderer } from "./xcsh-api-renderer";
|
|
30
31
|
|
|
31
32
|
type ToolRenderer = {
|
|
32
33
|
renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
|
|
@@ -68,4 +69,5 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
|
|
68
69
|
gh_run_watch: ghRunWatchToolRenderer as ToolRenderer,
|
|
69
70
|
web_search: webSearchToolRenderer as ToolRenderer,
|
|
70
71
|
write: writeToolRenderer as ToolRenderer,
|
|
72
|
+
xcsh_api: xcshApiToolRenderer as ToolRenderer,
|
|
71
73
|
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI renderer for the xcsh_api tool.
|
|
3
|
+
*
|
|
4
|
+
* Provides context-aware visualization for F5 XC API calls:
|
|
5
|
+
* - renderCall: method badge + path while request is pending
|
|
6
|
+
* - renderResult: status code badge + JSON body preview (collapsed/expanded)
|
|
7
|
+
*/
|
|
8
|
+
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
9
|
+
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
10
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
11
|
+
import type { Theme, ThemeColor } from "../modes/theme/theme";
|
|
12
|
+
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
|
13
|
+
import { formatErrorMessage, PREVIEW_LIMITS, replaceTabs } from "./render-utils";
|
|
14
|
+
import type { XcshApiToolDetails } from "./xcsh-api";
|
|
15
|
+
|
|
16
|
+
interface XcshApiRenderArgs {
|
|
17
|
+
method?: string;
|
|
18
|
+
path?: string;
|
|
19
|
+
params?: Record<string, string>;
|
|
20
|
+
payload?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Map HTTP method to a theme color for the badge. */
|
|
24
|
+
function methodColor(method: string): ThemeColor {
|
|
25
|
+
switch (method) {
|
|
26
|
+
case "GET":
|
|
27
|
+
return "accent";
|
|
28
|
+
case "DELETE":
|
|
29
|
+
return "error";
|
|
30
|
+
default:
|
|
31
|
+
return "warning";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Map HTTP status code to a theme color. */
|
|
36
|
+
function statusColor(status: number): ThemeColor {
|
|
37
|
+
if (status < 300) return "success";
|
|
38
|
+
if (status < 400) return "warning";
|
|
39
|
+
return "error";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const COLLAPSED_BODY_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
|
|
43
|
+
|
|
44
|
+
export const xcshApiToolRenderer = {
|
|
45
|
+
renderCall(args: XcshApiRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
46
|
+
const method = args.method ?? "???";
|
|
47
|
+
const apiPath = args.path ?? "…";
|
|
48
|
+
const text = renderStatusLine(
|
|
49
|
+
{
|
|
50
|
+
icon: "pending",
|
|
51
|
+
title: "API",
|
|
52
|
+
description: apiPath,
|
|
53
|
+
badge: { label: method, color: methodColor(method) },
|
|
54
|
+
},
|
|
55
|
+
uiTheme,
|
|
56
|
+
);
|
|
57
|
+
return new Text(text, 0, 0);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
renderResult(
|
|
61
|
+
result: { content: Array<{ type: string; text?: string }>; details?: XcshApiToolDetails; isError?: boolean },
|
|
62
|
+
options: RenderResultOptions,
|
|
63
|
+
uiTheme: Theme,
|
|
64
|
+
args?: XcshApiRenderArgs,
|
|
65
|
+
): Component {
|
|
66
|
+
const details = result.details;
|
|
67
|
+
const method = details?.method ?? args?.method ?? "???";
|
|
68
|
+
const url = details?.url;
|
|
69
|
+
// Show resolved path (from URL) or the original template path
|
|
70
|
+
let displayPath = args?.path ?? "…";
|
|
71
|
+
if (url) {
|
|
72
|
+
try {
|
|
73
|
+
displayPath = new URL(url).pathname;
|
|
74
|
+
} catch {
|
|
75
|
+
// Malformed URL — fall through to args.path
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const status = details?.status ?? 0;
|
|
79
|
+
const statusText = status > 0 ? `${status}` : "failed";
|
|
80
|
+
|
|
81
|
+
if (result.isError && !details) {
|
|
82
|
+
const errorText = result.content?.find(c => c.type === "text")?.text;
|
|
83
|
+
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const meta: string[] = [];
|
|
87
|
+
if (details?.contextName) meta.push(uiTheme.fg("muted", details.contextName));
|
|
88
|
+
if (details?.durationMs !== undefined) meta.push(uiTheme.fg("dim", `${details.durationMs}ms`));
|
|
89
|
+
const header = renderStatusLine(
|
|
90
|
+
{
|
|
91
|
+
title: "API",
|
|
92
|
+
description: displayPath,
|
|
93
|
+
badge: { label: `${method} ${statusText}`, color: status > 0 ? statusColor(status) : "error" },
|
|
94
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
95
|
+
},
|
|
96
|
+
uiTheme,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
100
|
+
// Split off the status line prefix (e.g. "200 OK\n\n") from the body
|
|
101
|
+
const bodyStart = textContent.indexOf("\n\n");
|
|
102
|
+
let body = bodyStart >= 0 ? textContent.slice(bodyStart + 2) : textContent;
|
|
103
|
+
// Format JSON bodies for readable TUI display (error bodies include guidance text and won't parse)
|
|
104
|
+
try {
|
|
105
|
+
body = JSON.stringify(JSON.parse(body.trim()), null, 2);
|
|
106
|
+
} catch {
|
|
107
|
+
// Not valid JSON — keep as-is
|
|
108
|
+
}
|
|
109
|
+
const bodyLines = body.trim() ? replaceTabs(body).split("\n") : [];
|
|
110
|
+
|
|
111
|
+
let cached: RenderCache | undefined;
|
|
112
|
+
return {
|
|
113
|
+
render(width: number): string[] {
|
|
114
|
+
const { expanded } = options;
|
|
115
|
+
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
116
|
+
if (cached?.key === key) return cached.lines;
|
|
117
|
+
|
|
118
|
+
const lines: string[] = [header];
|
|
119
|
+
|
|
120
|
+
if (bodyLines.length > 0) {
|
|
121
|
+
if (expanded) {
|
|
122
|
+
for (const line of bodyLines) {
|
|
123
|
+
lines.push(truncateToWidth(uiTheme.fg("toolOutput", line), width, Ellipsis.Omit));
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
const maxLines = COLLAPSED_BODY_LINES;
|
|
127
|
+
const display = bodyLines.slice(0, maxLines);
|
|
128
|
+
const remaining = bodyLines.length - maxLines;
|
|
129
|
+
for (const line of display) {
|
|
130
|
+
lines.push(truncateToWidth(uiTheme.fg("toolOutput", line), width, Ellipsis.Omit));
|
|
131
|
+
}
|
|
132
|
+
if (remaining > 0) {
|
|
133
|
+
lines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
cached = { key, lines };
|
|
139
|
+
return lines;
|
|
140
|
+
},
|
|
141
|
+
invalidate() {
|
|
142
|
+
cached = undefined;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
mergeCallAndResult: true,
|
|
147
|
+
};
|
package/src/tools/xcsh-api.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AgentTool, AgentToolResult } from "@f5xc-salesdemos/pi-agent-core"
|
|
|
2
2
|
import { prompt } from "@f5xc-salesdemos/pi-utils";
|
|
3
3
|
import { type Static, Type } from "@sinclair/typebox";
|
|
4
4
|
import xcshApiDescription from "../prompts/tools/xcsh-api.md" with { type: "text" };
|
|
5
|
-
import { createContextEnv } from "../services/context-env";
|
|
5
|
+
import { type ContextEnv, createContextEnv } from "../services/context-env";
|
|
6
6
|
import type { ToolSession } from ".";
|
|
7
7
|
|
|
8
8
|
const xcshApiSchema = Type.Object({
|
|
@@ -28,6 +28,10 @@ export interface XcshApiToolDetails {
|
|
|
28
28
|
url: string;
|
|
29
29
|
method: string;
|
|
30
30
|
requestId: string;
|
|
31
|
+
/** Round-trip duration in milliseconds. */
|
|
32
|
+
durationMs?: number;
|
|
33
|
+
/** Active context profile name, if available. */
|
|
34
|
+
contextName?: string;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
|
|
@@ -38,15 +42,23 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
38
42
|
readonly description: string;
|
|
39
43
|
readonly parameters = xcshApiSchema;
|
|
40
44
|
|
|
41
|
-
#contextEnv:
|
|
45
|
+
#contextEnv: ContextEnv;
|
|
46
|
+
/** Tracks the last API base for context-switch detection and TLS re-warm. */
|
|
47
|
+
#lastApiBase = "";
|
|
42
48
|
|
|
43
49
|
constructor(session: ToolSession) {
|
|
44
50
|
this.description = prompt.render(xcshApiDescription);
|
|
45
51
|
this.#contextEnv = createContextEnv(session.settings);
|
|
46
52
|
|
|
53
|
+
this.#warmTls();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Pre-warm TLS connection to the current context's API endpoint. */
|
|
57
|
+
#warmTls(): void {
|
|
47
58
|
const apiBase = this.#resolveApiBase();
|
|
48
59
|
const apiToken = this.#resolveApiToken();
|
|
49
60
|
if (apiBase && apiToken) {
|
|
61
|
+
this.#lastApiBase = apiBase;
|
|
50
62
|
fetch(`${apiBase}/api/web/namespaces`, {
|
|
51
63
|
method: "HEAD",
|
|
52
64
|
headers: { Authorization: `APIToken ${apiToken}` },
|
|
@@ -62,25 +74,67 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
62
74
|
return process.env.F5XC_API_TOKEN ?? this.#contextEnv.get("F5XC_API_TOKEN") ?? "";
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
|
|
77
|
+
#errorResult(text: string, details?: XcshApiToolDetails): XcshApiResult {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text }],
|
|
80
|
+
...(details ? { details } : {}),
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Context-aware guidance appended to HTTP error responses for common CRUD failures. */
|
|
86
|
+
#statusGuidance(status: number): string | null {
|
|
87
|
+
const ctx = this.#contextEnv.getContextName();
|
|
88
|
+
const ctxHint = ctx ? ` (context: \`${ctx}\`)` : "";
|
|
89
|
+
switch (status) {
|
|
90
|
+
case 401:
|
|
91
|
+
return `Token may be expired or invalid${ctxHint}. Run \`/context validate\` to check credentials, or \`/context create\` to set up a new context with fresh credentials.`;
|
|
92
|
+
case 403:
|
|
93
|
+
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
|
+
case 404: {
|
|
95
|
+
const ns = 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.`;
|
|
97
|
+
}
|
|
98
|
+
case 409:
|
|
99
|
+
return `Resource already exists${ctxHint}. Use PUT to replace the existing resource, or DELETE it first before creating a new one.`;
|
|
100
|
+
case 429:
|
|
101
|
+
return `API rate limit exceeded${ctxHint}. Wait briefly and retry the request.`;
|
|
102
|
+
default:
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async execute(_toolCallId: string, params: XcshApiParams, signal?: AbortSignal): Promise<XcshApiResult> {
|
|
66
108
|
const apiBase = this.#resolveApiBase();
|
|
109
|
+
// Detect context switch: API base changed since last call → re-warm TLS
|
|
110
|
+
if (apiBase && apiBase !== this.#lastApiBase) {
|
|
111
|
+
this.#warmTls();
|
|
112
|
+
}
|
|
67
113
|
if (!apiBase) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
114
|
+
const ctx = this.#contextEnv.getContextName();
|
|
115
|
+
const ctxNote = ctx ? ` Active context \`${ctx}\` has no API URL.` : "";
|
|
116
|
+
return this.#errorResult(
|
|
117
|
+
`Error: No API URL configured.${ctxNote} Activate a context with \`/context activate <name>\` or \`/context create\`, or set the F5XC_API_URL environment variable.`,
|
|
118
|
+
);
|
|
72
119
|
}
|
|
73
|
-
|
|
74
120
|
const apiToken = this.#resolveApiToken();
|
|
75
121
|
if (!apiToken) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
122
|
+
const ctx = this.#contextEnv.getContextName();
|
|
123
|
+
const ctxNote = ctx ? ` Active context \`${ctx}\` has no API token.` : "";
|
|
124
|
+
return this.#errorResult(
|
|
125
|
+
`Error: No API token configured.${ctxNote} Activate a context with \`/context activate <name>\` or \`/context create\`, or set the F5XC_API_TOKEN environment variable.`,
|
|
126
|
+
);
|
|
80
127
|
}
|
|
81
|
-
|
|
82
128
|
const resolvedPath = this.#contextEnv.resolvePath(params.path, params.params);
|
|
83
129
|
|
|
130
|
+
// Guard: detect unresolved {placeholder} params still remaining in the path.
|
|
131
|
+
// Regex matches \w+ (same as ContextEnv.resolvePath) to avoid misaligned detection.
|
|
132
|
+
const unresolvedPlaceholders = resolvedPath.match(/\{\w+\}/g);
|
|
133
|
+
if (unresolvedPlaceholders) {
|
|
134
|
+
return this.#errorResult(
|
|
135
|
+
`Error: Unresolved path parameter(s): ${unresolvedPlaceholders.join(", ")}. Provide them via \`params\` or ensure they are configured in the active context.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
84
138
|
const url = `${apiBase}${resolvedPath}`;
|
|
85
139
|
const requestId = crypto.randomUUID();
|
|
86
140
|
const headers: Record<string, string> = {
|
|
@@ -89,10 +143,14 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
89
143
|
"X-Request-ID": requestId,
|
|
90
144
|
};
|
|
91
145
|
|
|
146
|
+
// Combine user abort signal with 30s timeout. User Ctrl+C is respected.
|
|
147
|
+
const timeoutSignal = AbortSignal.timeout(30_000);
|
|
148
|
+
const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
149
|
+
|
|
92
150
|
const init: RequestInit = {
|
|
93
151
|
method: params.method,
|
|
94
152
|
headers,
|
|
95
|
-
signal:
|
|
153
|
+
signal: fetchSignal,
|
|
96
154
|
};
|
|
97
155
|
|
|
98
156
|
if (params.payload && params.method !== "GET") {
|
|
@@ -101,9 +159,11 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
101
159
|
init.body = this.#contextEnv.resolvePayloadVars(payloadJson);
|
|
102
160
|
}
|
|
103
161
|
|
|
162
|
+
const startMs = performance.now();
|
|
104
163
|
try {
|
|
105
164
|
const response = await fetch(url, init);
|
|
106
165
|
const raw = await response.text();
|
|
166
|
+
const durationMs = Math.round(performance.now() - startMs);
|
|
107
167
|
const contentType = response.headers.get("content-type") ?? "";
|
|
108
168
|
let bodyText = raw;
|
|
109
169
|
if (contentType.includes("application/json")) {
|
|
@@ -115,18 +175,41 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
115
175
|
}
|
|
116
176
|
const statusLine = `${response.status} ${response.statusText}`;
|
|
117
177
|
|
|
178
|
+
const contextName = this.#contextEnv.getContextName();
|
|
179
|
+
const detail = { status: response.status, url, method: params.method, requestId, durationMs, contextName };
|
|
180
|
+
|
|
181
|
+
// Context-aware CRUD error guidance for common HTTP status codes
|
|
182
|
+
const guidance = this.#statusGuidance(response.status);
|
|
183
|
+
if (guidance) {
|
|
184
|
+
return this.#errorResult(`${statusLine}\n\n${bodyText}\n\n${guidance}`, detail);
|
|
185
|
+
}
|
|
186
|
+
|
|
118
187
|
return {
|
|
119
188
|
content: [{ type: "text", text: `${statusLine}\n\n${bodyText}` }],
|
|
120
|
-
details:
|
|
189
|
+
details: detail,
|
|
121
190
|
...(response.status >= 400 ? { isError: true } : {}),
|
|
122
191
|
};
|
|
123
192
|
} catch (err) {
|
|
193
|
+
const durationMs = Math.round(performance.now() - startMs);
|
|
194
|
+
const contextName = this.#contextEnv.getContextName();
|
|
195
|
+
const detail = { status: 0, url, method: params.method, requestId, durationMs, contextName };
|
|
196
|
+
// Classify error: timeout vs DNS/network vs generic
|
|
197
|
+
const ctxLabel = contextName ? ` (context: \`${contextName}\`)` : "";
|
|
198
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
199
|
+
// User abort vs 30s timeout produce different AbortErrors
|
|
200
|
+
const message = signal?.aborted
|
|
201
|
+
? "Request cancelled."
|
|
202
|
+
: `Request timed out after 30s${ctxLabel}. The API endpoint may be unreachable. Verify the API URL with \`/context show\`.`;
|
|
203
|
+
return this.#errorResult(message, detail);
|
|
204
|
+
}
|
|
124
205
|
const message = err instanceof Error ? err.message : String(err);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
206
|
+
if (/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|dns/i.test(message)) {
|
|
207
|
+
return this.#errorResult(
|
|
208
|
+
`Network error${ctxLabel}: ${message}. The API URL may be incorrect. Check with \`/context show\`.`,
|
|
209
|
+
detail,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return this.#errorResult(`Request failed${ctxLabel}: ${message}`, detail);
|
|
130
213
|
}
|
|
131
214
|
}
|
|
132
215
|
}
|