pi-openai-toolkit 0.20.7 → 0.21.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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +8 -26
  4. package/README.zh.md +8 -26
  5. package/config.schema.json +278 -366
  6. package/docs/configuration.md +294 -310
  7. package/docs/internals.md +166 -175
  8. package/package.json +3 -13
  9. package/src/compact-client.ts +412 -389
  10. package/src/compaction-errors.ts +96 -0
  11. package/src/compaction-projection.ts +35 -35
  12. package/src/config/legacy.ts +519 -717
  13. package/src/config/migration.ts +103 -100
  14. package/src/config/notifications.ts +36 -28
  15. package/src/config/policy.ts +10 -18
  16. package/src/config/v2.ts +1167 -1380
  17. package/src/config.ts +310 -319
  18. package/src/context-management/codex-provider.ts +270 -270
  19. package/src/context-management/history-notes.ts +357 -357
  20. package/src/context-management/manual-compact.ts +0 -11
  21. package/src/context-management/tools.ts +371 -371
  22. package/src/context-management/types.ts +168 -168
  23. package/src/context-management/window-manager.ts +801 -801
  24. package/src/debug.ts +238 -237
  25. package/src/deferred-tool-carryover.ts +282 -282
  26. package/src/details-store.ts +151 -151
  27. package/src/extension-runtime.ts +1662 -1623
  28. package/src/image-generation/extension.ts +189 -189
  29. package/src/image-generation/protocol.ts +579 -579
  30. package/src/image-generation/types.ts +300 -300
  31. package/src/native-fallback.ts +4 -132
  32. package/src/payload-rewrite.ts +515 -518
  33. package/src/pi-context-hook.ts +117 -117
  34. package/src/remote-v2-client.ts +811 -814
  35. package/src/request-context-cache.ts +155 -155
  36. package/src/responses-headers.ts +180 -180
  37. package/src/runtime.ts +865 -832
  38. package/src/serializer.ts +882 -882
  39. package/src/supported-environment.ts +27 -27
  40. package/src/types.ts +565 -648
  41. package/src/web-search/alpha-client.ts +876 -876
  42. package/src/web-search/extension.ts +530 -530
  43. package/src/web-search/payload.ts +299 -299
  44. package/src/web-search/prompt.ts +60 -60
  45. package/src/web-search/types.ts +166 -166
  46. package/tsconfig.check.json +50 -54
  47. package/src/auto-mode/classifier.ts +0 -161
  48. package/src/auto-mode/extension.ts +0 -691
  49. package/src/auto-mode/model-selection-guard.ts +0 -52
  50. package/src/auto-mode/policy.ts +0 -146
  51. package/src/auto-mode/prompt.ts +0 -279
  52. package/src/auto-mode/reviewer.ts +0 -325
  53. package/src/auto-mode/tool-review-tui.ts +0 -211
  54. package/src/auto-mode/transcript.ts +0 -135
  55. package/src/auto-mode/types.ts +0 -137
@@ -1,530 +1,530 @@
1
- import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
- import { assertConfigValid, loadToolkitConfig, resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
3
- import { notifyConfigIssues } from "../config/notifications";
4
- import { registerPhysicalModelObserver, resolveResponsesEnvironment, type ResponsesEnvironmentResolution } from "../runtime";
5
- import {
6
- requestAlphaSearch,
7
- normalizeStandaloneWebRunCommands,
8
- sanitizeAlphaDiagnostic,
9
- STANDALONE_WEB_RUN_PARAMETERS,
10
- StandaloneWebRunError,
11
- type AlphaSearchDetails,
12
- type AlphaSearchClientResult,
13
- type StandaloneWebRunParameters,
14
- } from "./alpha-client";
15
- import { transformWebSearchPayload } from "./payload";
16
- import { appendWebSearchPrompt } from "./prompt";
17
- import {
18
- LOCAL_WEB_SEARCH_TOOL_NAME,
19
- resolveWebSearchRoute,
20
- WEB_RUN_TOOL_NAME,
21
- WEB_SEARCH_STANDALONE_APIS,
22
- type WebSearchModel,
23
- type WebSearchRouteResolution,
24
- } from "./types";
25
-
26
- const registeredApis = new WeakSet<object>();
27
-
28
- const WEB_RUN_LABEL = WEB_RUN_TOOL_NAME;
29
- const WEB_RUN_DESCRIPTION =
30
- "Run sequential standalone Web Search commands through the provider's CPA/Codex alpha-search endpoint. " +
31
- "Use only when the current Web Search route explicitly selects standalone-alpha.";
32
- const WEB_RUN_PROMPT_SNIPPET =
33
- "Run search, image, page, finance, weather, sports, or time commands through CPA/Codex standalone Web Search.";
34
- const WEB_RUN_PROMPT_GUIDELINES = [
35
- `Use ${WEB_RUN_TOOL_NAME} only when the configured Web Search route is standalone-alpha; do not assume it is available for an unconfigured model.`,
36
- "Choose exactly the command fields needed for the current request and preserve ref_id values for open, click, find, and screenshot follow-ups.",
37
- "Supported commands are search_query, image_query, open, click, find, screenshot, finance, weather, sports, and time; response_length may tune the result size.",
38
- "The route requires a CPA/Codex gateway that exposes /alpha/search and supports standalone web search; a failed request is not retried or redirected to another search implementation.",
39
- ];
40
-
41
- const DEFAULT_TOOL_STATE = {
42
- wasActiveBeforeOwnership: undefined as boolean | undefined,
43
- toolkitOwnsTool: false,
44
- };
45
-
46
- type ToolOwnershipState = {
47
- wasActiveBeforeOwnership?: boolean;
48
- toolkitOwnsTool: boolean;
49
- };
50
-
51
- type WebRunExecutorDependencies = {
52
- loadConfig: typeof loadToolkitConfig;
53
- resolveRuntime: typeof resolveResponsesEnvironment;
54
- requestSearch: typeof requestAlphaSearch;
55
- isRegistered: () => boolean;
56
- };
57
-
58
- function cloneToolState(): ToolOwnershipState {
59
- return { ...DEFAULT_TOOL_STATE };
60
- }
61
-
62
- function sanitizeMessage(message: string): string {
63
- return sanitizeAlphaDiagnostic(message, "Standalone Web Search failed.");
64
- }
65
-
66
- function activeToolsApi(pi: ExtensionAPI): {
67
- getActiveTools: () => string[];
68
- setActiveTools: (names: string[]) => void;
69
- } | undefined {
70
- const api = pi as ExtensionAPI & {
71
- getActiveTools?: () => string[];
72
- setActiveTools?: (names: string[]) => void;
73
- };
74
- if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") return undefined;
75
- return { getActiveTools: api.getActiveTools, setActiveTools: api.setActiveTools };
76
- }
77
-
78
- function canManageActiveTools(pi: ExtensionAPI): boolean {
79
- const api = activeToolsApi(pi);
80
- if (!api) return false;
81
- try {
82
- api.getActiveTools();
83
- return true;
84
- } catch {
85
- return false;
86
- }
87
- }
88
-
89
- function isActiveTool(pi: ExtensionAPI, name: string): boolean | undefined {
90
- const api = activeToolsApi(pi);
91
- if (!api) return undefined;
92
- try {
93
- return api.getActiveTools().includes(name);
94
- } catch {
95
- return undefined;
96
- }
97
- }
98
-
99
- function setActiveToolsIfChanged(
100
- api: { getActiveTools: () => string[]; setActiveTools: (names: string[]) => void },
101
- current: string[],
102
- next: string[],
103
- ): void {
104
- if (current.length === next.length && current.every((name, index) => name === next[index])) return;
105
- api.setActiveTools(next);
106
- }
107
-
108
- function claimAndRemove(
109
- pi: ExtensionAPI,
110
- name: string,
111
- state: ToolOwnershipState,
112
- ): void {
113
- const api = activeToolsApi(pi);
114
- if (!api) return;
115
- try {
116
- const current = api.getActiveTools();
117
- if (!state.toolkitOwnsTool) {
118
- state.wasActiveBeforeOwnership = current.includes(name);
119
- state.toolkitOwnsTool = true;
120
- }
121
- const next = current.filter((toolName) => toolName !== name);
122
- setActiveToolsIfChanged(api, current, next);
123
- } catch {
124
- // An unavailable active-tool API is handled as an unavailable route by the
125
- // execution guard; it must never trigger a different search implementation.
126
- }
127
- }
128
-
129
- function activateOwned(pi: ExtensionAPI, name: string, state: ToolOwnershipState): void {
130
- const api = activeToolsApi(pi);
131
- if (!api) return;
132
- try {
133
- const current = api.getActiveTools();
134
- if (!state.toolkitOwnsTool) {
135
- state.wasActiveBeforeOwnership = current.includes(name);
136
- state.toolkitOwnsTool = true;
137
- }
138
- let keptName = false;
139
- const next = current.filter((toolName) => {
140
- if (toolName !== name) return true;
141
- if (keptName) return false;
142
- keptName = true;
143
- return true;
144
- });
145
- if (!keptName) next.push(name);
146
- setActiveToolsIfChanged(api, current, next);
147
- } catch {
148
- // The tool-call guard remains fail closed if active-tool mutation fails.
149
- }
150
- }
151
-
152
- function releaseOwnership(
153
- pi: ExtensionAPI,
154
- name: string,
155
- state: ToolOwnershipState,
156
- ): void {
157
- if (!state.toolkitOwnsTool) return;
158
- const api = activeToolsApi(pi);
159
- if (!api) {
160
- state.wasActiveBeforeOwnership = undefined;
161
- state.toolkitOwnsTool = false;
162
- return;
163
- }
164
- try {
165
- const current = api.getActiveTools();
166
- const withoutOwnedName = current.filter((toolName) => toolName !== name);
167
- const next = state.wasActiveBeforeOwnership ? [...withoutOwnedName, name] : withoutOwnedName;
168
- setActiveToolsIfChanged(api, current, next);
169
- } catch {
170
- // Do not claim that a failed release restored a third-party tool.
171
- }
172
- state.wasActiveBeforeOwnership = undefined;
173
- state.toolkitOwnsTool = false;
174
- }
175
-
176
- function describeRouteFailure(resolution: WebSearchRouteResolution): string {
177
- if (resolution.route === "none") {
178
- return resolution.reason === "disabled"
179
- ? "Web Search is disabled."
180
- : "No Web Search route is configured for the current provider/model-id.";
181
- }
182
- return `Web Search route "${resolution.route}" is unavailable${resolution.reason ? ` (${resolution.reason})` : ""}.`;
183
- }
184
-
185
- function abortAndThrow(ctx: ExtensionContext, message: string): never {
186
- try {
187
- ctx.abort();
188
- } catch {
189
- // Test harnesses and older hosts may omit abort; throwing still prevents
190
- // Pi from sending a payload that violates the selected route.
191
- }
192
- throw new Error(sanitizeMessage(message));
193
- }
194
-
195
- function standaloneRuntimeFailure(resolution: ResponsesEnvironmentResolution): Error {
196
- if (resolution.ok) return new Error("Unexpected standalone Web Search runtime state.");
197
- const reason = resolution.reason;
198
- switch (reason) {
199
- case "missing-api-key":
200
- case "auth-resolution-failed":
201
- return new Error(
202
- sanitizeMessage(
203
- resolution.errorMessage ??
204
- "Standalone Web Search authentication could not be resolved for the current model.",
205
- ),
206
- );
207
- case "missing-base-url":
208
- return new Error("Standalone Web Search has no usable provider base URL.");
209
- case "missing-session-id":
210
- return new Error("Standalone Web Search requires a session identity for this gateway route.");
211
- case "unsupported-api":
212
- return new Error("Standalone Web Search requires an openai-responses or openai-codex-responses model.");
213
- default:
214
- return new Error(`Standalone Web Search runtime is unavailable (${reason}).`);
215
- }
216
- }
217
-
218
- export async function executeStandaloneWebRun(args: {
219
- params: unknown;
220
- signal?: AbortSignal;
221
- ctx: ExtensionContext;
222
- loadConfig?: typeof loadToolkitConfig;
223
- resolveRuntime?: typeof resolveResponsesEnvironment;
224
- requestSearch?: typeof requestAlphaSearch;
225
- isRegistered?: () => boolean;
226
- }): Promise<{ text: string; details: AlphaSearchDetails }> {
227
- const commands = normalizeStandaloneWebRunCommands(args.params);
228
- if (args.signal?.aborted) throw new StandaloneWebRunError(`${WEB_RUN_TOOL_NAME} was cancelled.`);
229
-
230
- const loadConfig = args.loadConfig ?? loadToolkitConfig;
231
- const resolveRuntime = args.resolveRuntime ?? resolveResponsesEnvironment;
232
- const requestSearch = args.requestSearch ?? requestAlphaSearch;
233
- if (args.isRegistered && !args.isRegistered()) {
234
- throw new Error(`${WEB_RUN_TOOL_NAME} is not registered by the toolkit; standalone Web Search is unavailable.`);
235
- }
236
-
237
- const resolved = resolveToolkitConfig(loadConfig(), args.ctx.model);
238
- notifyConfigIssues(args.ctx, resolved);
239
- assertConfigValid(resolved, "webSearch", "compatibility");
240
- const { config } = resolved;
241
- const route = resolveWebSearchRoute({ model: args.ctx.model, config: config.webSearch });
242
- if (route.route !== "standalone-alpha" || !route.available) {
243
- throw new Error(describeRouteFailure(route));
244
- }
245
-
246
- const runtime = await resolveRuntime(args.ctx, {
247
- enabled: config.webSearch.enabled,
248
- responsesApis: WEB_SEARCH_STANDALONE_APIS,
249
- codexGatewayModels: resolved.gatewayModelKeys,
250
- });
251
- if (!runtime.ok) throw standaloneRuntimeFailure(runtime);
252
-
253
- const result: AlphaSearchClientResult = await requestSearch({
254
- runtime: runtime.runtime,
255
- commands,
256
- signal: args.signal,
257
- });
258
- if (!result.ok) throw new Error(sanitizeMessage(result.errorMessage));
259
- return { text: result.output, details: result.details };
260
- }
261
-
262
- function createWebRunTool(deps: WebRunExecutorDependencies): ToolDefinition<
263
- typeof STANDALONE_WEB_RUN_PARAMETERS,
264
- AlphaSearchDetails
265
- > {
266
- return {
267
- name: WEB_RUN_TOOL_NAME,
268
- label: WEB_RUN_LABEL,
269
- description: WEB_RUN_DESCRIPTION,
270
- promptSnippet: WEB_RUN_PROMPT_SNIPPET,
271
- promptGuidelines: WEB_RUN_PROMPT_GUIDELINES,
272
- parameters: STANDALONE_WEB_RUN_PARAMETERS,
273
- executionMode: "sequential",
274
- async execute(_toolCallId, params: StandaloneWebRunParameters, signal, _onUpdate, ctx) {
275
- try {
276
- const result = await executeStandaloneWebRun({
277
- params,
278
- signal,
279
- ctx,
280
- loadConfig: deps.loadConfig,
281
- resolveRuntime: deps.resolveRuntime,
282
- requestSearch: deps.requestSearch,
283
- isRegistered: deps.isRegistered,
284
- });
285
- return {
286
- content: [{ type: "text", text: result.text }],
287
- details: result.details,
288
- };
289
- } catch (error) {
290
- const message = error instanceof Error ? error.message : String(error);
291
- throw new Error(sanitizeMessage(message));
292
- }
293
- },
294
- };
295
- }
296
-
297
- type StandaloneRegistrationState = "verified" | "excluded" | "unavailable";
298
-
299
- function verifyStandaloneRegistration(
300
- pi: ExtensionAPI,
301
- definition: ToolDefinition<typeof STANDALONE_WEB_RUN_PARAMETERS, AlphaSearchDetails>,
302
- ): StandaloneRegistrationState {
303
- try {
304
- const api = pi as ExtensionAPI & {
305
- getAllTools?: () => Array<{
306
- name: string;
307
- description: string;
308
- parameters: unknown;
309
- promptGuidelines?: string[];
310
- }>;
311
- };
312
- if (typeof api.getAllTools !== "function") return "unavailable";
313
- const actual = api.getAllTools().find((tool) => tool.name === WEB_RUN_TOOL_NAME);
314
- // Pi publishes only tools permitted by --tools/--exclude-tools/--no-tools.
315
- // After successful registration, an absent definition means this session
316
- // cannot use search; it is not a reason to abort ordinary model requests.
317
- if (actual === undefined) return "excluded";
318
- return actual.description === definition.description &&
319
- actual.parameters === definition.parameters &&
320
- actual.promptGuidelines === definition.promptGuidelines ? "verified" : "unavailable";
321
- } catch {
322
- return "unavailable";
323
- }
324
- }
325
-
326
- function syncWebSearchRoute(
327
- pi: ExtensionAPI,
328
- model: WebSearchModel | undefined,
329
- config: Parameters<typeof resolveWebSearchRoute>[0]["config"],
330
- states: { webSearch: ToolOwnershipState; webRun: ToolOwnershipState },
331
- standaloneReady: boolean,
332
- ): WebSearchRouteResolution {
333
- const resolution = resolveWebSearchRoute({ model, config });
334
- if (resolution.route === "local" && resolution.available) {
335
- releaseOwnership(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
336
- claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
337
- return resolution;
338
- }
339
- if (resolution.route === "standalone-alpha") {
340
- claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
341
- if (resolution.available && standaloneReady) {
342
- activateOwned(pi, WEB_RUN_TOOL_NAME, states.webRun);
343
- } else {
344
- claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
345
- }
346
- return resolution;
347
- }
348
- if (resolution.route === "hosted") {
349
- claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
350
- claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
351
- return resolution;
352
- }
353
-
354
- // `none`, and an explicit local route with no model, are fail-closed states:
355
- // release any toolkit ownership, but do not activate another implementation.
356
- if (resolution.route === "none" || (resolution.route === "local" && !resolution.available)) {
357
- releaseOwnership(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
358
- releaseOwnership(pi, WEB_RUN_TOOL_NAME, states.webRun);
359
- } else {
360
- claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
361
- claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
362
- }
363
- return resolution;
364
- }
365
-
366
- function routeReadyForPrompt(
367
- resolution: WebSearchRouteResolution,
368
- standaloneReady: boolean,
369
- ): boolean {
370
- return resolution.route !== "none" && resolution.available &&
371
- (resolution.route !== "standalone-alpha" || standaloneReady);
372
- }
373
-
374
- export function registerWebSearchExtension(
375
- pi: ExtensionAPI,
376
- loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
377
- requestSearch: typeof requestAlphaSearch = requestAlphaSearch,
378
- resolveRuntime: typeof resolveResponsesEnvironment = resolveResponsesEnvironment,
379
- ): void {
380
- if (registeredApis.has(pi)) return;
381
- registeredApis.add(pi);
382
- registerPhysicalModelObserver(pi);
383
-
384
- const states = { webSearch: cloneToolState(), webRun: cloneToolState() };
385
- let standaloneRegistrationSucceeded = false;
386
-
387
- const standaloneTool = createWebRunTool({
388
- loadConfig,
389
- resolveRuntime,
390
- requestSearch,
391
- isRegistered: () => standaloneReady(),
392
- });
393
-
394
- try {
395
- const api = pi as ExtensionAPI & { registerTool?: (tool: typeof standaloneTool) => void };
396
- if (typeof api.registerTool === "function") {
397
- api.registerTool(standaloneTool);
398
- standaloneRegistrationSucceeded = true;
399
- // Pi initially activates newly registered extension tools. Treat that
400
- // activation as toolkit-owned rather than as a third-party preference;
401
- // otherwise an unconfigured route would leak web_run into every prompt.
402
- states.webRun = { wasActiveBeforeOwnership: false, toolkitOwnsTool: true };
403
- }
404
- } catch {
405
- standaloneRegistrationSucceeded = false;
406
- }
407
-
408
- function standaloneRegistrationState(): StandaloneRegistrationState {
409
- if (!standaloneRegistrationSucceeded || !canManageActiveTools(pi)) return "unavailable";
410
- // Recheck on every lifecycle boundary so a later extension cannot replace
411
- // the definition after startup and leave a stale active name executable.
412
- // Do not use getAllTools() during extension loading; lifecycle callbacks
413
- // are the first point where Pi's action methods are bound.
414
- return verifyStandaloneRegistration(pi, standaloneTool);
415
- }
416
-
417
- function standaloneReady(): boolean {
418
- return standaloneRegistrationState() === "verified";
419
- }
420
-
421
- function readConfig(model: WebSearchModel | undefined, ctx?: ExtensionContext): ResolvedToolkitConfig {
422
- const resolved = resolveToolkitConfig(loadConfig(), model);
423
- if (ctx) notifyConfigIssues(ctx, resolved);
424
- return resolved;
425
- }
426
-
427
- function synchronizeResolved(model: WebSearchModel | undefined, resolved: ResolvedToolkitConfig): WebSearchRouteResolution {
428
- if (resolved.invalidFeatures.some((feature) => feature === "webSearch" || feature === "compatibility")) {
429
- claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
430
- claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
431
- return { route: "none", source: "none", modelKey: resolved.modelKey, reason: "unconfigured" };
432
- }
433
- return syncWebSearchRoute(pi, model, resolved.config.webSearch, states, standaloneReady());
434
- }
435
-
436
- function synchronize(model: WebSearchModel | undefined, ctx?: ExtensionContext): WebSearchRouteResolution {
437
- return synchronizeResolved(model, readConfig(model, ctx));
438
- }
439
-
440
- pi.on("session_start", (_event, ctx) => {
441
- synchronize(ctx.model, ctx);
442
- });
443
-
444
- pi.on("model_select", (event, ctx) => {
445
- synchronize(event.model, ctx);
446
- });
447
-
448
- pi.on("before_agent_start", (event, ctx) => {
449
- const resolved = readConfig(ctx.model, ctx);
450
- const { config } = resolved;
451
- const resolution = synchronizeResolved(ctx.model, resolved);
452
- const localToolIsActive = resolution.route === "local"
453
- ? isActiveTool(pi, LOCAL_WEB_SEARCH_TOOL_NAME)
454
- : undefined;
455
- const promptRouteReady = routeReadyForPrompt(resolution, standaloneReady()) &&
456
- (resolution.route !== "local" || localToolIsActive === true);
457
- const systemPrompt = appendWebSearchPrompt({
458
- model: ctx.model,
459
- config: config.webSearch,
460
- systemPrompt: event.systemPrompt,
461
- ...(promptRouteReady ? {} : { routeAvailable: false }),
462
- });
463
- return systemPrompt === event.systemPrompt ? undefined : { systemPrompt };
464
- });
465
-
466
- pi.on("before_provider_request", (event, ctx) => {
467
- const resolved = readConfig(ctx.model, ctx);
468
- try { assertConfigValid(resolved, "webSearch", "compatibility"); }
469
- catch (error) { abortAndThrow(ctx, error instanceof Error ? error.message : "Invalid Web Search configuration."); }
470
- const { config } = resolved;
471
- const resolution = synchronizeResolved(ctx.model, resolved);
472
- const registration = standaloneRegistrationState();
473
- if (resolution.route === "standalone-alpha" && resolution.available && registration === "unavailable") {
474
- abortAndThrow(ctx, `${WEB_RUN_TOOL_NAME} is not registered by the toolkit; standalone Web Search request aborted.`);
475
- }
476
- const transformed = transformWebSearchPayload({
477
- model: ctx.model,
478
- config: config.webSearch,
479
- payload: event.payload,
480
- standaloneToolExcluded: registration === "excluded",
481
- });
482
- if (transformed.fatal) {
483
- abortAndThrow(ctx, transformed.errorMessage ?? "Web Search route request aborted.");
484
- }
485
- return transformed.changed ? transformed.payload : undefined;
486
- });
487
-
488
- pi.on("tool_call", (event, ctx) => {
489
- if (event.toolName !== LOCAL_WEB_SEARCH_TOOL_NAME && event.toolName !== WEB_RUN_TOOL_NAME) {
490
- return undefined;
491
- }
492
- const resolved = readConfig(ctx.model, ctx);
493
- const resolution = synchronizeResolved(ctx.model, resolved);
494
- try { assertConfigValid(resolved, "webSearch", "compatibility"); }
495
- catch (error) { return { block: true, reason: error instanceof Error ? error.message : "Invalid Web Search configuration." }; }
496
- if (event.toolName === LOCAL_WEB_SEARCH_TOOL_NAME) {
497
- if (resolution.route === "none") return undefined;
498
- if (resolution.route === "local" && resolution.available) {
499
- if (isActiveTool(pi, LOCAL_WEB_SEARCH_TOOL_NAME) === true) return undefined;
500
- return {
501
- block: true,
502
- reason: "The local web_search tool is not active for the selected local Web Search route; the call was blocked instead of falling back.",
503
- };
504
- }
505
- return {
506
- block: true,
507
- reason: `${describeRouteFailure(resolution)} The local web_search call was blocked instead of falling back.`,
508
- };
509
- }
510
- if (resolution.route === "none") return undefined;
511
- if (resolution.route === "standalone-alpha" && resolution.available && standaloneReady()) {
512
- return undefined;
513
- }
514
- return {
515
- block: true,
516
- reason: `${describeRouteFailure(resolution)} The ${WEB_RUN_TOOL_NAME} call was blocked instead of falling back.`,
517
- };
518
- });
519
- }
520
-
521
- export default function webSearchExtension(pi: ExtensionAPI): void {
522
- registerWebSearchExtension(pi);
523
- }
524
-
525
- export const _extensionTest = {
526
- createWebRunTool,
527
- verifyStandaloneRegistration,
528
- syncWebSearchRoute,
529
- standaloneRuntimeFailure,
530
- };
1
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { assertConfigValid, loadToolkitConfig, resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
3
+ import { notifyConfigIssues } from "../config/notifications";
4
+ import { registerPhysicalModelObserver, resolveResponsesEnvironment, type ResponsesEnvironmentResolution } from "../runtime";
5
+ import {
6
+ requestAlphaSearch,
7
+ normalizeStandaloneWebRunCommands,
8
+ sanitizeAlphaDiagnostic,
9
+ STANDALONE_WEB_RUN_PARAMETERS,
10
+ StandaloneWebRunError,
11
+ type AlphaSearchDetails,
12
+ type AlphaSearchClientResult,
13
+ type StandaloneWebRunParameters,
14
+ } from "./alpha-client";
15
+ import { transformWebSearchPayload } from "./payload";
16
+ import { appendWebSearchPrompt } from "./prompt";
17
+ import {
18
+ LOCAL_WEB_SEARCH_TOOL_NAME,
19
+ resolveWebSearchRoute,
20
+ WEB_RUN_TOOL_NAME,
21
+ WEB_SEARCH_STANDALONE_APIS,
22
+ type WebSearchModel,
23
+ type WebSearchRouteResolution,
24
+ } from "./types";
25
+
26
+ const registeredApis = new WeakSet<object>();
27
+
28
+ const WEB_RUN_LABEL = WEB_RUN_TOOL_NAME;
29
+ const WEB_RUN_DESCRIPTION =
30
+ "Run sequential standalone Web Search commands through the provider's CPA/Codex alpha-search endpoint. " +
31
+ "Use only when the current Web Search route explicitly selects standalone-alpha.";
32
+ const WEB_RUN_PROMPT_SNIPPET =
33
+ "Run search, image, page, finance, weather, sports, or time commands through CPA/Codex standalone Web Search.";
34
+ const WEB_RUN_PROMPT_GUIDELINES = [
35
+ `Use ${WEB_RUN_TOOL_NAME} only when the configured Web Search route is standalone-alpha; do not assume it is available for an unconfigured model.`,
36
+ "Choose exactly the command fields needed for the current request and preserve ref_id values for open, click, find, and screenshot follow-ups.",
37
+ "Supported commands are search_query, image_query, open, click, find, screenshot, finance, weather, sports, and time; response_length may tune the result size.",
38
+ "The route requires a CPA/Codex gateway that exposes /alpha/search and supports standalone web search; a failed request is not retried or redirected to another search implementation.",
39
+ ];
40
+
41
+ const DEFAULT_TOOL_STATE = {
42
+ wasActiveBeforeOwnership: undefined as boolean | undefined,
43
+ toolkitOwnsTool: false,
44
+ };
45
+
46
+ type ToolOwnershipState = {
47
+ wasActiveBeforeOwnership?: boolean;
48
+ toolkitOwnsTool: boolean;
49
+ };
50
+
51
+ type WebRunExecutorDependencies = {
52
+ loadConfig: typeof loadToolkitConfig;
53
+ resolveRuntime: typeof resolveResponsesEnvironment;
54
+ requestSearch: typeof requestAlphaSearch;
55
+ isRegistered: () => boolean;
56
+ };
57
+
58
+ function cloneToolState(): ToolOwnershipState {
59
+ return { ...DEFAULT_TOOL_STATE };
60
+ }
61
+
62
+ function sanitizeMessage(message: string): string {
63
+ return sanitizeAlphaDiagnostic(message, "Standalone Web Search failed.");
64
+ }
65
+
66
+ function activeToolsApi(pi: ExtensionAPI): {
67
+ getActiveTools: () => string[];
68
+ setActiveTools: (names: string[]) => void;
69
+ } | undefined {
70
+ const api = pi as ExtensionAPI & {
71
+ getActiveTools?: () => string[];
72
+ setActiveTools?: (names: string[]) => void;
73
+ };
74
+ if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") return undefined;
75
+ return { getActiveTools: api.getActiveTools, setActiveTools: api.setActiveTools };
76
+ }
77
+
78
+ function canManageActiveTools(pi: ExtensionAPI): boolean {
79
+ const api = activeToolsApi(pi);
80
+ if (!api) return false;
81
+ try {
82
+ api.getActiveTools();
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function isActiveTool(pi: ExtensionAPI, name: string): boolean | undefined {
90
+ const api = activeToolsApi(pi);
91
+ if (!api) return undefined;
92
+ try {
93
+ return api.getActiveTools().includes(name);
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ function setActiveToolsIfChanged(
100
+ api: { getActiveTools: () => string[]; setActiveTools: (names: string[]) => void },
101
+ current: string[],
102
+ next: string[],
103
+ ): void {
104
+ if (current.length === next.length && current.every((name, index) => name === next[index])) return;
105
+ api.setActiveTools(next);
106
+ }
107
+
108
+ function claimAndRemove(
109
+ pi: ExtensionAPI,
110
+ name: string,
111
+ state: ToolOwnershipState,
112
+ ): void {
113
+ const api = activeToolsApi(pi);
114
+ if (!api) return;
115
+ try {
116
+ const current = api.getActiveTools();
117
+ if (!state.toolkitOwnsTool) {
118
+ state.wasActiveBeforeOwnership = current.includes(name);
119
+ state.toolkitOwnsTool = true;
120
+ }
121
+ const next = current.filter((toolName) => toolName !== name);
122
+ setActiveToolsIfChanged(api, current, next);
123
+ } catch {
124
+ // An unavailable active-tool API is handled as an unavailable route by the
125
+ // execution guard; it must never trigger a different search implementation.
126
+ }
127
+ }
128
+
129
+ function activateOwned(pi: ExtensionAPI, name: string, state: ToolOwnershipState): void {
130
+ const api = activeToolsApi(pi);
131
+ if (!api) return;
132
+ try {
133
+ const current = api.getActiveTools();
134
+ if (!state.toolkitOwnsTool) {
135
+ state.wasActiveBeforeOwnership = current.includes(name);
136
+ state.toolkitOwnsTool = true;
137
+ }
138
+ let keptName = false;
139
+ const next = current.filter((toolName) => {
140
+ if (toolName !== name) return true;
141
+ if (keptName) return false;
142
+ keptName = true;
143
+ return true;
144
+ });
145
+ if (!keptName) next.push(name);
146
+ setActiveToolsIfChanged(api, current, next);
147
+ } catch {
148
+ // The tool-call guard remains fail closed if active-tool mutation fails.
149
+ }
150
+ }
151
+
152
+ function releaseOwnership(
153
+ pi: ExtensionAPI,
154
+ name: string,
155
+ state: ToolOwnershipState,
156
+ ): void {
157
+ if (!state.toolkitOwnsTool) return;
158
+ const api = activeToolsApi(pi);
159
+ if (!api) {
160
+ state.wasActiveBeforeOwnership = undefined;
161
+ state.toolkitOwnsTool = false;
162
+ return;
163
+ }
164
+ try {
165
+ const current = api.getActiveTools();
166
+ const withoutOwnedName = current.filter((toolName) => toolName !== name);
167
+ const next = state.wasActiveBeforeOwnership ? [...withoutOwnedName, name] : withoutOwnedName;
168
+ setActiveToolsIfChanged(api, current, next);
169
+ } catch {
170
+ // Do not claim that a failed release restored a third-party tool.
171
+ }
172
+ state.wasActiveBeforeOwnership = undefined;
173
+ state.toolkitOwnsTool = false;
174
+ }
175
+
176
+ function describeRouteFailure(resolution: WebSearchRouteResolution): string {
177
+ if (resolution.route === "none") {
178
+ return resolution.reason === "disabled"
179
+ ? "Web Search is disabled."
180
+ : "No Web Search route is configured for the current provider/model-id.";
181
+ }
182
+ return `Web Search route "${resolution.route}" is unavailable${resolution.reason ? ` (${resolution.reason})` : ""}.`;
183
+ }
184
+
185
+ function abortAndThrow(ctx: ExtensionContext, message: string): never {
186
+ try {
187
+ ctx.abort();
188
+ } catch {
189
+ // Test harnesses and older hosts may omit abort; throwing still prevents
190
+ // Pi from sending a payload that violates the selected route.
191
+ }
192
+ throw new Error(sanitizeMessage(message));
193
+ }
194
+
195
+ function standaloneRuntimeFailure(resolution: ResponsesEnvironmentResolution): Error {
196
+ if (resolution.ok) return new Error("Unexpected standalone Web Search runtime state.");
197
+ const reason = resolution.reason;
198
+ switch (reason) {
199
+ case "missing-api-key":
200
+ case "auth-resolution-failed":
201
+ return new Error(
202
+ sanitizeMessage(
203
+ resolution.errorMessage ??
204
+ "Standalone Web Search authentication could not be resolved for the current model.",
205
+ ),
206
+ );
207
+ case "missing-base-url":
208
+ return new Error("Standalone Web Search has no usable provider base URL.");
209
+ case "missing-session-id":
210
+ return new Error("Standalone Web Search requires a session identity for this gateway route.");
211
+ case "unsupported-api":
212
+ return new Error("Standalone Web Search requires an openai-responses or openai-codex-responses model.");
213
+ default:
214
+ return new Error(`Standalone Web Search runtime is unavailable (${reason}).`);
215
+ }
216
+ }
217
+
218
+ export async function executeStandaloneWebRun(args: {
219
+ params: unknown;
220
+ signal?: AbortSignal;
221
+ ctx: ExtensionContext;
222
+ loadConfig?: typeof loadToolkitConfig;
223
+ resolveRuntime?: typeof resolveResponsesEnvironment;
224
+ requestSearch?: typeof requestAlphaSearch;
225
+ isRegistered?: () => boolean;
226
+ }): Promise<{ text: string; details: AlphaSearchDetails }> {
227
+ const commands = normalizeStandaloneWebRunCommands(args.params);
228
+ if (args.signal?.aborted) throw new StandaloneWebRunError(`${WEB_RUN_TOOL_NAME} was cancelled.`);
229
+
230
+ const loadConfig = args.loadConfig ?? loadToolkitConfig;
231
+ const resolveRuntime = args.resolveRuntime ?? resolveResponsesEnvironment;
232
+ const requestSearch = args.requestSearch ?? requestAlphaSearch;
233
+ if (args.isRegistered && !args.isRegistered()) {
234
+ throw new Error(`${WEB_RUN_TOOL_NAME} is not registered by the toolkit; standalone Web Search is unavailable.`);
235
+ }
236
+
237
+ const resolved = resolveToolkitConfig(loadConfig(), args.ctx.model);
238
+ notifyConfigIssues(args.ctx, resolved);
239
+ assertConfigValid(resolved, "webSearch", "compatibility");
240
+ const { config } = resolved;
241
+ const route = resolveWebSearchRoute({ model: args.ctx.model, config: config.webSearch });
242
+ if (route.route !== "standalone-alpha" || !route.available) {
243
+ throw new Error(describeRouteFailure(route));
244
+ }
245
+
246
+ const runtime = await resolveRuntime(args.ctx, {
247
+ enabled: config.webSearch.enabled,
248
+ responsesApis: WEB_SEARCH_STANDALONE_APIS,
249
+ codexGatewayModels: resolved.gatewayModelKeys,
250
+ });
251
+ if (!runtime.ok) throw standaloneRuntimeFailure(runtime);
252
+
253
+ const result: AlphaSearchClientResult = await requestSearch({
254
+ runtime: runtime.runtime,
255
+ commands,
256
+ signal: args.signal,
257
+ });
258
+ if (!result.ok) throw new Error(sanitizeMessage(result.errorMessage));
259
+ return { text: result.output, details: result.details };
260
+ }
261
+
262
+ function createWebRunTool(deps: WebRunExecutorDependencies): ToolDefinition<
263
+ typeof STANDALONE_WEB_RUN_PARAMETERS,
264
+ AlphaSearchDetails
265
+ > {
266
+ return {
267
+ name: WEB_RUN_TOOL_NAME,
268
+ label: WEB_RUN_LABEL,
269
+ description: WEB_RUN_DESCRIPTION,
270
+ promptSnippet: WEB_RUN_PROMPT_SNIPPET,
271
+ promptGuidelines: WEB_RUN_PROMPT_GUIDELINES,
272
+ parameters: STANDALONE_WEB_RUN_PARAMETERS,
273
+ executionMode: "sequential",
274
+ async execute(_toolCallId, params: StandaloneWebRunParameters, signal, _onUpdate, ctx) {
275
+ try {
276
+ const result = await executeStandaloneWebRun({
277
+ params,
278
+ signal,
279
+ ctx,
280
+ loadConfig: deps.loadConfig,
281
+ resolveRuntime: deps.resolveRuntime,
282
+ requestSearch: deps.requestSearch,
283
+ isRegistered: deps.isRegistered,
284
+ });
285
+ return {
286
+ content: [{ type: "text", text: result.text }],
287
+ details: result.details,
288
+ };
289
+ } catch (error) {
290
+ const message = error instanceof Error ? error.message : String(error);
291
+ throw new Error(sanitizeMessage(message));
292
+ }
293
+ },
294
+ };
295
+ }
296
+
297
+ type StandaloneRegistrationState = "verified" | "excluded" | "unavailable";
298
+
299
+ function verifyStandaloneRegistration(
300
+ pi: ExtensionAPI,
301
+ definition: ToolDefinition<typeof STANDALONE_WEB_RUN_PARAMETERS, AlphaSearchDetails>,
302
+ ): StandaloneRegistrationState {
303
+ try {
304
+ const api = pi as ExtensionAPI & {
305
+ getAllTools?: () => Array<{
306
+ name: string;
307
+ description: string;
308
+ parameters: unknown;
309
+ promptGuidelines?: string[];
310
+ }>;
311
+ };
312
+ if (typeof api.getAllTools !== "function") return "unavailable";
313
+ const actual = api.getAllTools().find((tool) => tool.name === WEB_RUN_TOOL_NAME);
314
+ // Pi publishes only tools permitted by --tools/--exclude-tools/--no-tools.
315
+ // After successful registration, an absent definition means this session
316
+ // cannot use search; it is not a reason to abort ordinary model requests.
317
+ if (actual === undefined) return "excluded";
318
+ return actual.description === definition.description &&
319
+ actual.parameters === definition.parameters &&
320
+ actual.promptGuidelines === definition.promptGuidelines ? "verified" : "unavailable";
321
+ } catch {
322
+ return "unavailable";
323
+ }
324
+ }
325
+
326
+ function syncWebSearchRoute(
327
+ pi: ExtensionAPI,
328
+ model: WebSearchModel | undefined,
329
+ config: Parameters<typeof resolveWebSearchRoute>[0]["config"],
330
+ states: { webSearch: ToolOwnershipState; webRun: ToolOwnershipState },
331
+ standaloneReady: boolean,
332
+ ): WebSearchRouteResolution {
333
+ const resolution = resolveWebSearchRoute({ model, config });
334
+ if (resolution.route === "local" && resolution.available) {
335
+ releaseOwnership(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
336
+ claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
337
+ return resolution;
338
+ }
339
+ if (resolution.route === "standalone-alpha") {
340
+ claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
341
+ if (resolution.available && standaloneReady) {
342
+ activateOwned(pi, WEB_RUN_TOOL_NAME, states.webRun);
343
+ } else {
344
+ claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
345
+ }
346
+ return resolution;
347
+ }
348
+ if (resolution.route === "hosted") {
349
+ claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
350
+ claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
351
+ return resolution;
352
+ }
353
+
354
+ // `none`, and an explicit local route with no model, are fail-closed states:
355
+ // release any toolkit ownership, but do not activate another implementation.
356
+ if (resolution.route === "none" || (resolution.route === "local" && !resolution.available)) {
357
+ releaseOwnership(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
358
+ releaseOwnership(pi, WEB_RUN_TOOL_NAME, states.webRun);
359
+ } else {
360
+ claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
361
+ claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
362
+ }
363
+ return resolution;
364
+ }
365
+
366
+ function routeReadyForPrompt(
367
+ resolution: WebSearchRouteResolution,
368
+ standaloneReady: boolean,
369
+ ): boolean {
370
+ return resolution.route !== "none" && resolution.available &&
371
+ (resolution.route !== "standalone-alpha" || standaloneReady);
372
+ }
373
+
374
+ export function registerWebSearchExtension(
375
+ pi: ExtensionAPI,
376
+ loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
377
+ requestSearch: typeof requestAlphaSearch = requestAlphaSearch,
378
+ resolveRuntime: typeof resolveResponsesEnvironment = resolveResponsesEnvironment,
379
+ ): void {
380
+ if (registeredApis.has(pi)) return;
381
+ registeredApis.add(pi);
382
+ registerPhysicalModelObserver(pi);
383
+
384
+ const states = { webSearch: cloneToolState(), webRun: cloneToolState() };
385
+ let standaloneRegistrationSucceeded = false;
386
+
387
+ const standaloneTool = createWebRunTool({
388
+ loadConfig,
389
+ resolveRuntime,
390
+ requestSearch,
391
+ isRegistered: () => standaloneReady(),
392
+ });
393
+
394
+ try {
395
+ const api = pi as ExtensionAPI & { registerTool?: (tool: typeof standaloneTool) => void };
396
+ if (typeof api.registerTool === "function") {
397
+ api.registerTool(standaloneTool);
398
+ standaloneRegistrationSucceeded = true;
399
+ // Pi initially activates newly registered extension tools. Treat that
400
+ // activation as toolkit-owned rather than as a third-party preference;
401
+ // otherwise an unconfigured route would leak web_run into every prompt.
402
+ states.webRun = { wasActiveBeforeOwnership: false, toolkitOwnsTool: true };
403
+ }
404
+ } catch {
405
+ standaloneRegistrationSucceeded = false;
406
+ }
407
+
408
+ function standaloneRegistrationState(): StandaloneRegistrationState {
409
+ if (!standaloneRegistrationSucceeded || !canManageActiveTools(pi)) return "unavailable";
410
+ // Recheck on every lifecycle boundary so a later extension cannot replace
411
+ // the definition after startup and leave a stale active name executable.
412
+ // Do not use getAllTools() during extension loading; lifecycle callbacks
413
+ // are the first point where Pi's action methods are bound.
414
+ return verifyStandaloneRegistration(pi, standaloneTool);
415
+ }
416
+
417
+ function standaloneReady(): boolean {
418
+ return standaloneRegistrationState() === "verified";
419
+ }
420
+
421
+ function readConfig(model: WebSearchModel | undefined, ctx?: ExtensionContext): ResolvedToolkitConfig {
422
+ const resolved = resolveToolkitConfig(loadConfig(), model);
423
+ if (ctx) notifyConfigIssues(ctx, resolved);
424
+ return resolved;
425
+ }
426
+
427
+ function synchronizeResolved(model: WebSearchModel | undefined, resolved: ResolvedToolkitConfig): WebSearchRouteResolution {
428
+ if (resolved.invalidFeatures.some((feature) => feature === "webSearch" || feature === "compatibility")) {
429
+ claimAndRemove(pi, LOCAL_WEB_SEARCH_TOOL_NAME, states.webSearch);
430
+ claimAndRemove(pi, WEB_RUN_TOOL_NAME, states.webRun);
431
+ return { route: "none", source: "none", modelKey: resolved.modelKey, reason: "unconfigured" };
432
+ }
433
+ return syncWebSearchRoute(pi, model, resolved.config.webSearch, states, standaloneReady());
434
+ }
435
+
436
+ function synchronize(model: WebSearchModel | undefined, ctx?: ExtensionContext): WebSearchRouteResolution {
437
+ return synchronizeResolved(model, readConfig(model, ctx));
438
+ }
439
+
440
+ pi.on("session_start", (_event, ctx) => {
441
+ synchronize(ctx.model, ctx);
442
+ });
443
+
444
+ pi.on("model_select", (event, ctx) => {
445
+ synchronize(event.model, ctx);
446
+ });
447
+
448
+ pi.on("before_agent_start", (event, ctx) => {
449
+ const resolved = readConfig(ctx.model, ctx);
450
+ const { config } = resolved;
451
+ const resolution = synchronizeResolved(ctx.model, resolved);
452
+ const localToolIsActive = resolution.route === "local"
453
+ ? isActiveTool(pi, LOCAL_WEB_SEARCH_TOOL_NAME)
454
+ : undefined;
455
+ const promptRouteReady = routeReadyForPrompt(resolution, standaloneReady()) &&
456
+ (resolution.route !== "local" || localToolIsActive === true);
457
+ const systemPrompt = appendWebSearchPrompt({
458
+ model: ctx.model,
459
+ config: config.webSearch,
460
+ systemPrompt: event.systemPrompt,
461
+ ...(promptRouteReady ? {} : { routeAvailable: false }),
462
+ });
463
+ return systemPrompt === event.systemPrompt ? undefined : { systemPrompt };
464
+ });
465
+
466
+ pi.on("before_provider_request", (event, ctx) => {
467
+ const resolved = readConfig(ctx.model, ctx);
468
+ try { assertConfigValid(resolved, "webSearch", "compatibility"); }
469
+ catch (error) { abortAndThrow(ctx, error instanceof Error ? error.message : "Invalid Web Search configuration."); }
470
+ const { config } = resolved;
471
+ const resolution = synchronizeResolved(ctx.model, resolved);
472
+ const registration = standaloneRegistrationState();
473
+ if (resolution.route === "standalone-alpha" && resolution.available && registration === "unavailable") {
474
+ abortAndThrow(ctx, `${WEB_RUN_TOOL_NAME} is not registered by the toolkit; standalone Web Search request aborted.`);
475
+ }
476
+ const transformed = transformWebSearchPayload({
477
+ model: ctx.model,
478
+ config: config.webSearch,
479
+ payload: event.payload,
480
+ standaloneToolExcluded: registration === "excluded",
481
+ });
482
+ if (transformed.fatal) {
483
+ abortAndThrow(ctx, transformed.errorMessage ?? "Web Search route request aborted.");
484
+ }
485
+ return transformed.changed ? transformed.payload : undefined;
486
+ });
487
+
488
+ pi.on("tool_call", (event, ctx) => {
489
+ if (event.toolName !== LOCAL_WEB_SEARCH_TOOL_NAME && event.toolName !== WEB_RUN_TOOL_NAME) {
490
+ return undefined;
491
+ }
492
+ const resolved = readConfig(ctx.model, ctx);
493
+ const resolution = synchronizeResolved(ctx.model, resolved);
494
+ try { assertConfigValid(resolved, "webSearch", "compatibility"); }
495
+ catch (error) { return { block: true, reason: error instanceof Error ? error.message : "Invalid Web Search configuration." }; }
496
+ if (event.toolName === LOCAL_WEB_SEARCH_TOOL_NAME) {
497
+ if (resolution.route === "none") return undefined;
498
+ if (resolution.route === "local" && resolution.available) {
499
+ if (isActiveTool(pi, LOCAL_WEB_SEARCH_TOOL_NAME) === true) return undefined;
500
+ return {
501
+ block: true,
502
+ reason: "The local web_search tool is not active for the selected local Web Search route; the call was blocked instead of falling back.",
503
+ };
504
+ }
505
+ return {
506
+ block: true,
507
+ reason: `${describeRouteFailure(resolution)} The local web_search call was blocked instead of falling back.`,
508
+ };
509
+ }
510
+ if (resolution.route === "none") return undefined;
511
+ if (resolution.route === "standalone-alpha" && resolution.available && standaloneReady()) {
512
+ return undefined;
513
+ }
514
+ return {
515
+ block: true,
516
+ reason: `${describeRouteFailure(resolution)} The ${WEB_RUN_TOOL_NAME} call was blocked instead of falling back.`,
517
+ };
518
+ });
519
+ }
520
+
521
+ export default function webSearchExtension(pi: ExtensionAPI): void {
522
+ registerWebSearchExtension(pi);
523
+ }
524
+
525
+ export const _extensionTest = {
526
+ createWebRunTool,
527
+ verifyStandaloneRegistration,
528
+ syncWebSearchRoute,
529
+ standaloneRuntimeFailure,
530
+ };