@f5-sales-demo/xcsh 19.88.0 → 19.89.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": "@f5-sales-demo/xcsh",
4
- "version": "19.88.0",
4
+ "version": "19.89.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.88.0",
60
- "@f5-sales-demo/pi-agent-core": "19.88.0",
61
- "@f5-sales-demo/pi-ai": "19.88.0",
62
- "@f5-sales-demo/pi-natives": "19.88.0",
63
- "@f5-sales-demo/pi-resource-management": "19.88.0",
64
- "@f5-sales-demo/pi-tui": "19.88.0",
65
- "@f5-sales-demo/pi-utils": "19.88.0",
59
+ "@f5-sales-demo/xcsh-stats": "19.89.1",
60
+ "@f5-sales-demo/pi-agent-core": "19.89.1",
61
+ "@f5-sales-demo/pi-ai": "19.89.1",
62
+ "@f5-sales-demo/pi-natives": "19.89.1",
63
+ "@f5-sales-demo/pi-resource-management": "19.89.1",
64
+ "@f5-sales-demo/pi-tui": "19.89.1",
65
+ "@f5-sales-demo/pi-utils": "19.89.1",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -1,4 +1,7 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { AssistantMessage, ImageContent } from "@f5-sales-demo/pi-ai";
4
+ import { settings } from "../config/settings";
2
5
  import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
3
6
  import {
4
7
  isRpcHostToolResult,
@@ -24,11 +27,13 @@ import {
24
27
  isChatRequest,
25
28
  isChatStop,
26
29
  isConfigure,
30
+ isListSkills,
27
31
  isSetHostTools,
28
32
  type PageContextSnapshot,
29
33
  type SetHostTools,
30
34
  type SetHostToolsAck,
31
35
  type SetHostToolsError,
36
+ type SkillsList,
32
37
  } from "./chat-protocol";
33
38
  import { CONSOLE_ROUTES } from "./console-routes.generated";
34
39
  import type { BridgeServer } from "./extension-bridge";
@@ -101,6 +106,9 @@ export class ChatHandler {
101
106
  // Provider configuration channel (#2095): swap the LLM provider/model in
102
107
  // session memory at runtime (never persisted), then ack or nack.
103
108
  else if (isConfigure(msg)) this.#handleConfigure(msg as unknown as Configure);
109
+ // Skills enumeration (#2311): the pane asks for the loaded skills to populate
110
+ // the composer's Skills submenu.
111
+ else if (isListSkills(msg)) this.#handleListSkills();
104
112
  else if (isRpcHostToolResult(msg)) this.#hostToolBridge.handleResult(msg as unknown as HostToolResult);
105
113
  else if (isRpcHostToolUpdate(msg)) this.#hostToolBridge.handleUpdate(msg as unknown as HostToolUpdate);
106
114
  });
@@ -164,7 +172,13 @@ export class ChatHandler {
164
172
  });
165
173
  chat.unsubscribe = unsubscribe;
166
174
 
167
- const prompt = composeChatPrompt(req.text, req.context, req.mode, this.#server.clientHost);
175
+ // Sanitize the attached paths (absolute, `..`-collapsed, confined to the user's
176
+ // own space, no control chars) — the engine must not blindly widen its sandbox to
177
+ // a client-supplied path. Grant the safe set BEFORE composing the prompt so the
178
+ // model's read/grep/bash calls pass the gate. Session-scoped, in-memory, deduped.
179
+ const contextPaths = Array.isArray(req.contextPaths) ? sanitizeContextPaths(req.contextPaths) : [];
180
+ if (contextPaths.length > 0) grantSandboxPaths(contextPaths);
181
+ const prompt = composeChatPrompt(req.text, req.context, req.mode, this.#server.clientHost, contextPaths);
168
182
  // Photo/image attachments ride as base64 vision blocks (the model is
169
183
  // vision-capable); text attachments are already folded into req.text upstream.
170
184
  const images: ImageContent[] | undefined = req.images?.map(img => ({
@@ -172,10 +186,16 @@ export class ChatHandler {
172
186
  data: img.data,
173
187
  mimeType: img.mimeType,
174
188
  }));
189
+ // "Search the web" toggle → add Anthropic's server-side web-search tool for this
190
+ // turn only. The gateway executes it and returns cited results; source URLs the
191
+ // model writes inline flow to the pane's Sources chips via extractReferences.
192
+ const serverTools = req.web_search
193
+ ? [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }]
194
+ : undefined;
175
195
 
176
196
  try {
177
197
  chat.promptAt = Date.now();
178
- await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false, images });
198
+ await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false, images, serverTools });
179
199
  } catch (err: unknown) {
180
200
  const message = err instanceof Error ? err.message : "unknown error";
181
201
  this.#sendTerminal(chat, { type: "chat_error", id, error: message, reason: classifyChatErrorReason(message) });
@@ -377,6 +397,15 @@ export class ChatHandler {
377
397
  this.#session.agent.abort();
378
398
  }
379
399
 
400
+ /** Reply to `list_skills` with the session's live skills (name + description) so
401
+ * the pane can populate the composer's Skills submenu. Pure read — skills are
402
+ * already loaded on the session; the model actions them via the read tool + the
403
+ * system prompt (Phase 2A enabled `read`), so this is enumeration only. */
404
+ #handleListSkills(): void {
405
+ const skills = this.#session.skills.map(s => ({ name: s.name, description: s.description }));
406
+ this.#server.send({ type: "skills", skills } satisfies SkillsList);
407
+ }
408
+
380
409
  #sendTerminal(chat: ActiveChat, frame: ChatDone | ChatError): void {
381
410
  if (chat.terminalSent) return;
382
411
  chat.terminalSent = true;
@@ -455,11 +484,79 @@ const MODE_INSTRUCTIONS: Record<InteractionMode, string> = {
455
484
  annotation: "Create on-page teaching annotations that highlight key elements and explain their purpose.",
456
485
  };
457
486
 
487
+ /** Bases a user-attached context path must fall under. Confines grants to the user's
488
+ * own space (home, the project cwd, temp, external volumes, /opt) and thereby blocks
489
+ * a client from widening the sandbox to system/credential dirs (`/etc`, `/var`,
490
+ * `/usr`, `/System`, other users' homes, `/`). */
491
+ function contextPathAllowedBases(): string[] {
492
+ const bases = [process.cwd(), "/tmp", "/private/tmp", "/Volumes", "/opt"];
493
+ const home = process.env.HOME ?? process.env.USERPROFILE;
494
+ if (home) bases.push(home);
495
+ // Canonicalize with realpath so BOTH sides of the containment check are symlink-free
496
+ // (a symlink base like /tmp→/private/tmp must compare against the real target).
497
+ return bases.map(b => {
498
+ try {
499
+ return fs.realpathSync(b);
500
+ } catch {
501
+ return path.resolve(b);
502
+ }
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Canonicalize + validate the user-attached context paths BEFORE they widen the
508
+ * sandbox or are named in the prompt. Rejects anything that isn't an absolute path
509
+ * confined to {@link contextPathAllowedBases} (after collapsing `..`), and anything
510
+ * carrying control characters (a prompt-injection vector). Deduped. This is the trust
511
+ * boundary: the engine must not blindly grant a client-supplied path to its own
512
+ * filesystem sandbox.
513
+ */
514
+ export function sanitizeContextPaths(paths: string[]): string[] {
515
+ const bases = contextPathAllowedBases();
516
+ const seen = new Set<string>();
517
+ const out: string[] = [];
518
+ for (const p of paths) {
519
+ if (typeof p !== "string" || p.length === 0 || /[\n\r\0]/.test(p) || !path.isAbsolute(p)) continue;
520
+ // realpathSync resolves symlinks — a link INSIDE an allowed base that points out to
521
+ // /etc canonicalizes to /etc and fails the containment check — AND requires the
522
+ // path to exist, so a non-existent (typo'd/spoofed) attachment is skipped.
523
+ let real: string;
524
+ try {
525
+ real = fs.realpathSync(p);
526
+ } catch {
527
+ continue;
528
+ }
529
+ const within = bases.some(base => real === base || real.startsWith(base + path.sep));
530
+ if (!within || seen.has(real)) continue;
531
+ seen.add(real);
532
+ out.push(real);
533
+ }
534
+ return out;
535
+ }
536
+
537
+ /**
538
+ * Append absolute context paths to the session's sandbox read-allowlist. In-memory
539
+ * (`override`, never persisted) and deduped, so re-attaching a path is a no-op. The
540
+ * sandbox-guard keys its policy cache on the allow-list, so the grant takes effect on
541
+ * the model's next file tool call. Best-effort: a pre-init settings proxy is tolerated.
542
+ * Callers MUST pass paths through {@link sanitizeContextPaths} first.
543
+ */
544
+ export function grantSandboxPaths(paths: string[]): void {
545
+ try {
546
+ const current = (settings.get("sandbox.allowRead") as string[] | undefined) ?? [];
547
+ const merged = Array.from(new Set([...current, ...paths]));
548
+ if (merged.length !== current.length) settings.override("sandbox.allowRead", merged);
549
+ } catch {
550
+ /* settings not initialized (some SDK/test contexts) — the grant is best-effort */
551
+ }
552
+ }
553
+
458
554
  export function composeChatPrompt(
459
555
  text: string,
460
556
  context: PageContextSnapshot | null,
461
557
  mode: InteractionMode,
462
558
  host: ClientHost | null,
559
+ contextPaths?: string[],
463
560
  ): string {
464
561
  const parts: string[] = [];
465
562
 
@@ -477,6 +574,17 @@ export function composeChatPrompt(
477
574
  if (context) composeBrowserPageContext(parts, context);
478
575
  }
479
576
 
577
+ // User-attached local context paths (files/folders). They're granted to the
578
+ // sandbox alongside this, so the model may read them on demand — tell it they exist.
579
+ if (contextPaths && contextPaths.length > 0) {
580
+ parts.push("");
581
+ // JSON.stringify each path: an unambiguous, escaped boundary so a path can't
582
+ // blur into surrounding prompt text (belt-and-suspenders — they're pre-sanitized).
583
+ parts.push(
584
+ `The user attached these local paths as context; read them with your tools (read/grep/bash) as needed:\n${contextPaths.map(p => `- ${JSON.stringify(p)}`).join("\n")}`,
585
+ );
586
+ }
587
+
480
588
  parts.push("");
481
589
  parts.push(text);
482
590
  return parts.join("\n");
@@ -85,6 +85,48 @@ export interface ChatRequest {
85
85
  history_hint?: string;
86
86
  /** Optional photo/image attachments, sent to the model as vision blocks. */
87
87
  images?: ChatImage[];
88
+ /** Absolute local paths (files/folders) the user attached as context. The engine
89
+ * grants them to the filesystem sandbox for the session and tells the model they
90
+ * are available to read on demand. */
91
+ contextPaths?: string[];
92
+ /** When true, the engine adds Anthropic's server-side web-search tool to this
93
+ * turn's request (the "Search the web" composer toggle). */
94
+ web_search?: boolean;
95
+ }
96
+
97
+ /** Client → engine: open a native OS file/folder picker on the machine running the
98
+ * bridge and return the chosen absolute path. */
99
+ export interface PickPath {
100
+ type: "pick_path";
101
+ mode: "file" | "folder";
102
+ }
103
+
104
+ /** Engine → client: the picker result. `path` is set on success; `canceled` when the
105
+ * user dismissed the dialog; `unsupported` when the platform has no native picker
106
+ * (the pane then falls back to manual path entry). */
107
+ export interface PathPicked {
108
+ type: "path_picked";
109
+ path?: string;
110
+ canceled?: boolean;
111
+ unsupported?: boolean;
112
+ }
113
+
114
+ /** Client → engine: enumerate the session's loaded skills for the composer's
115
+ * Skills submenu. Sent once after the pane connects. */
116
+ export interface ListSkills {
117
+ type: "list_skills";
118
+ }
119
+
120
+ /** One skill surfaced to the pane's Skills submenu (name + human description). */
121
+ export interface SkillInfo {
122
+ name: string;
123
+ description: string;
124
+ }
125
+
126
+ /** Engine → client: the session's live skills, in load order. */
127
+ export interface SkillsList {
128
+ type: "skills";
129
+ skills: SkillInfo[];
88
130
  }
89
131
 
90
132
  export interface ChatStop {
@@ -266,6 +308,14 @@ export function isChatStop(msg: Record<string, unknown>): boolean {
266
308
  return msg.type === "chat_stop" && hasChatIdPrefix(msg.id);
267
309
  }
268
310
 
311
+ export function isListSkills(msg: Record<string, unknown>): boolean {
312
+ return msg.type === "list_skills";
313
+ }
314
+
315
+ export function isPickPath(msg: Record<string, unknown>): boolean {
316
+ return msg.type === "pick_path" && (msg.mode === "file" || msg.mode === "folder");
317
+ }
318
+
269
319
  export function isSetHostTools(msg: Record<string, unknown>): boolean {
270
320
  return msg.type === "set_host_tools" && Array.isArray(msg.tools);
271
321
  }
@@ -20,8 +20,10 @@ import { ContextService } from "../services/xcsh-context";
20
20
  import { deriveTenantEnv } from "../services/xcsh-env";
21
21
  import { resolveBridgeTls } from "./bridge-cert";
22
22
  import { ChatHandler } from "./chat-handler";
23
+ import { isPickPath, type PathPicked } from "./chat-protocol";
23
24
  import { type BridgeServer, OFFICE_PORT_RANGE, startBridgeServer } from "./extension-bridge";
24
25
  import { OFFICE_TOOL_NAMES } from "./extension-bridge-tools";
26
+ import { pickPathNative } from "./native-picker";
25
27
  import { setSharedBridgeServer } from "./provider";
26
28
 
27
29
  /** A running headless bridge + a teardown that disposes the chat handler and
@@ -69,6 +71,9 @@ export interface HeadlessBridgeDeps {
69
71
  setSharedBridgeServer: typeof setSharedBridgeServer;
70
72
  createAgentSession: typeof createAgentSession;
71
73
  ChatHandlerCtor: typeof ChatHandler;
74
+ /** Native OS file/folder picker (macOS osascript by default). Injectable so tests
75
+ * don't open a real dialog. */
76
+ pickPath: typeof pickPathNative;
72
77
  }
73
78
 
74
79
  const defaultDeps: HeadlessBridgeDeps = {
@@ -96,6 +101,7 @@ const defaultDeps: HeadlessBridgeDeps = {
96
101
  setSharedBridgeServer,
97
102
  createAgentSession,
98
103
  ChatHandlerCtor: ChatHandler,
104
+ pickPath: pickPathNative,
99
105
  };
100
106
 
101
107
  /**
@@ -157,9 +163,23 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
157
163
  const chatHandler = new deps.ChatHandlerCtor(bridge, session);
158
164
  chatHandler.attach();
159
165
 
166
+ // Second bridge subscriber (the bridge fans out to all): serve `pick_path` by
167
+ // opening a native OS picker and replying `path_picked`. Pure — it only returns
168
+ // the chosen path; the sandbox grant + prompt note happen in ChatHandler when the
169
+ // path rides the next `chat_request.contextPaths` (kept atomic there). `disposed`
170
+ // guards against a superseded serve firing the picker on a closed bridge.
171
+ let disposed = false;
172
+ bridge.onMessage(async msg => {
173
+ if (disposed || !isPickPath(msg)) return;
174
+ const { path, canceled, unsupported } = await deps.pickPath((msg as { mode: "file" | "folder" }).mode);
175
+ if (disposed) return;
176
+ bridge.send({ type: "path_picked", path, canceled, unsupported } satisfies PathPicked);
177
+ });
178
+
160
179
  return {
161
180
  bridge,
162
181
  dispose: async () => {
182
+ disposed = true;
163
183
  chatHandler.dispose();
164
184
  deps.setSharedBridgeServer(null);
165
185
  await bridge.close();
@@ -94,6 +94,10 @@ SAFETY — NEVER DO THESE:
94
94
  const OFFICE_NATIVE_TOOLS_NOTE = `
95
95
  NATIVE TOOLS: Beyond the document host tools, you have xcsh's full local toolset — \`bash\` (run shell commands, including CLIs like \`az\`, \`gh\`, \`terraform\`, \`git\` when installed and authenticated), file tools (\`read\`/\`write\`/\`edit\`), and \`grep\` — plus any skills available in this workspace. Reach for them when the task genuinely needs them (pull live data with a CLI, read a local file the user points you at). Prefer the document host tools for document work. Your file tools and shell are confined to the folder xcsh was launched from.
96
96
 
97
+ This pane runs NO MCP servers and NO plugin-provided tools — the tools listed above (plus the document host tools) are everything you have. Do not look for, read, or report on plugin/MCP manifests: to use a cloud or SCM CLI, invoke it directly with \`bash\` (e.g. \`az account show\`, \`gh repo view\`). The user sees your narration, so don't describe missing plugins as failures — just use the CLI.
98
+
99
+ SKILLS: When a message begins with \`/<skill-name>\` naming one of your available skills, treat it as a request to USE that skill — read its instructions (open \`skill://<skill-name>\`, or its SKILL.md via \`read\`) and follow them, applying any text after the name as the skill's input.
100
+
97
101
  SAFETY:
98
102
  - NEVER kill, stop, inspect, or manage the xcsh \`office serve\` process, its bridge ports, or any xcsh process — that bridge IS you; ending it ends the session.
99
103
  - NEVER run \`lsof\`/\`fuser\`/\`kill\`/\`pkill\` against the bridge ports or use the shell to manage xcsh itself.`;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Native OS file/folder picker for the local Office bridge.
3
+ *
4
+ * An Office task-pane WebView cannot open a local file dialog, but the bridge runs
5
+ * in-process on the user's machine, so it can. macOS uses `osascript`'s `choose
6
+ * file`/`choose folder`; other platforms have no one-liner equivalent, so the pane
7
+ * falls back to manual path entry there.
8
+ *
9
+ * ASYNC on purpose: the dialog blocks until the user responds, so a synchronous
10
+ * spawn would stall the bridge's event loop (heartbeats, in-flight turns) for the
11
+ * whole time it's open. `Bun.spawn` + `await proc.exited` keeps the bridge live.
12
+ */
13
+
14
+ export interface PickResult {
15
+ ok: boolean;
16
+ /** Absolute path chosen (no trailing slash), when `ok`. */
17
+ path?: string;
18
+ /** The user dismissed the dialog. */
19
+ canceled?: boolean;
20
+ /** This platform has no native picker; the pane should prompt for a path instead. */
21
+ unsupported?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Open a native picker and resolve the chosen absolute path. Never throws — every
26
+ * failure maps to a `PickResult` flag so the caller can respond gracefully.
27
+ */
28
+ export async function pickPathNative(mode: "file" | "folder"): Promise<PickResult> {
29
+ // macOS only for now — osascript is not present on win32/linux.
30
+ if (process.platform !== "darwin") return { ok: false, unsupported: true };
31
+ const script = mode === "folder" ? "POSIX path of (choose folder)" : "POSIX path of (choose file)";
32
+ try {
33
+ const proc = Bun.spawn(["osascript", "-e", script], { stdout: "pipe", stderr: "ignore" });
34
+ const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
35
+ // Cancel raises AppleScript error -128 → non-zero exit, empty stdout.
36
+ if (code !== 0) return { ok: false, canceled: true };
37
+ let path = out.trim();
38
+ if (!path) return { ok: false, canceled: true };
39
+ // `choose folder` yields a trailing slash; normalize to a bare dir path so it
40
+ // matches the sandbox allow-rule root exactly.
41
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
42
+ return { ok: true, path };
43
+ } catch {
44
+ // osascript missing/blocked — treat as unsupported so the pane offers manual entry.
45
+ return { ok: false, unsupported: true };
46
+ }
47
+ }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.88.0",
21
- "commit": "4e87c996191e7c88ae503cedded3afee98177587",
22
- "shortCommit": "4e87c99",
20
+ "version": "19.89.1",
21
+ "commit": "9211f03109bde7193ba45c405270aaad282bab6d",
22
+ "shortCommit": "9211f03",
23
23
  "branch": "main",
24
- "tag": "v19.88.0",
25
- "commitDate": "2026-07-24T18:57:08Z",
26
- "buildDate": "2026-07-24T19:23:46.325Z",
24
+ "tag": "v19.89.1",
25
+ "commitDate": "2026-07-25T00:32:53Z",
26
+ "buildDate": "2026-07-25T00:57:48.294Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/4e87c996191e7c88ae503cedded3afee98177587",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.88.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/9211f03109bde7193ba45c405270aaad282bab6d",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.89.1"
33
33
  };
@@ -167,7 +167,7 @@ export const TERRAFORM_INDEX: TerraformIndex = {
167
167
  name: "Uncategorized",
168
168
  slug: "uncategorized",
169
169
  description: "Resources pending categorization",
170
- resource_count: 6,
170
+ resource_count: 7,
171
171
  resources: [
172
172
  "application_profiles",
173
173
  "authorization_server",
@@ -175,6 +175,7 @@ export const TERRAFORM_INDEX: TerraformIndex = {
175
175
  "mitigated_domain",
176
176
  "protected_application",
177
177
  "protected_domain",
178
+ "registration_approval",
178
179
  ],
179
180
  },
180
181
  {
@@ -1427,6 +1428,17 @@ export const TERRAFORM_INDEX: TerraformIndex = {
1427
1428
  },
1428
1429
  import_syntax: "terraform import xcsh_registration.example namespace/name",
1429
1430
  },
1431
+ registration_approval: {
1432
+ category: "uncategorized",
1433
+ description: "Request for admission approval. configuration",
1434
+ required: ["name", "namespace"],
1435
+ minimal_config:
1436
+ 'resource "xcsh_registration_approval" "example" {\n name = "example-registration-approval"\n namespace = "staging"\n}',
1437
+ dependencies: {
1438
+ requires: [],
1439
+ },
1440
+ import_syntax: "terraform import xcsh_registration_approval.example namespace/name",
1441
+ },
1430
1442
  route: {
1431
1443
  category: "load-balancing",
1432
1444
  description:
@@ -277,6 +277,12 @@ export interface PromptOptions {
277
277
  attribution?: MessageAttribution;
278
278
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
279
279
  skipCompactionCheck?: boolean;
280
+ /**
281
+ * Raw provider "server tool" specs to inject into THIS turn's request tools, e.g.
282
+ * Anthropic's `{type:"web_search_20250305", name:"web_search", max_uses:N}`. Rides
283
+ * the provider `onPayload` seam (per-turn, no client-side tool registration).
284
+ */
285
+ serverTools?: Record<string, unknown>[];
280
286
  }
281
287
 
282
288
  /** Result from cycleModel() */
@@ -2632,7 +2638,7 @@ export class AgentSession {
2632
2638
  async #promptWithMessage(
2633
2639
  message: AgentMessage,
2634
2640
  expandedText: string,
2635
- options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
2641
+ options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck" | "serverTools"> & {
2636
2642
  prependMessages?: AgentMessage[];
2637
2643
  skipPostPromptRecoveryWait?: boolean;
2638
2644
  },
@@ -2753,7 +2759,10 @@ export class AgentSession {
2753
2759
  return;
2754
2760
  }
2755
2761
 
2756
- const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
2762
+ const agentPromptOptions =
2763
+ options?.toolChoice || options?.serverTools
2764
+ ? { toolChoice: options?.toolChoice, serverTools: options?.serverTools }
2765
+ : undefined;
2757
2766
  await logger.ttftAttr("ttft.agent-loop-total", () =>
2758
2767
  this.#promptAgentWithIdleRetry(messages, agentPromptOptions),
2759
2768
  );
@@ -5601,7 +5610,10 @@ export class AgentSession {
5601
5610
  this.#resolveRetry();
5602
5611
  }
5603
5612
 
5604
- async #promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
5613
+ async #promptAgentWithIdleRetry(
5614
+ messages: AgentMessage[],
5615
+ options?: { toolChoice?: ToolChoice; serverTools?: Record<string, unknown>[] },
5616
+ ): Promise<void> {
5605
5617
  const deadline = Date.now() + 30_000;
5606
5618
  for (;;) {
5607
5619
  try {