pi-openai-toolkit 0.14.10 → 0.14.11

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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.14.10",
3
+ "version": "0.14.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -12,13 +12,25 @@ import type { HistoryAction, NotesAction } from "./types";
12
12
 
13
13
  const EMPTY_PARAMETERS = Type.Object({}, { additionalProperties: false });
14
14
 
15
- function arrayEquals(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
16
- if (a === b) return true;
17
- if (!a || !b || a.length !== b.length) return false;
18
- for (let i = 0; i < a.length; i++) {
19
- if (a[i] !== b[i]) return false;
20
- }
21
- return true;
15
+ type PublishedTool = {
16
+ name: string;
17
+ description: string;
18
+ parameters: unknown;
19
+ promptGuidelines?: string[];
20
+ };
21
+
22
+ /**
23
+ * Pi's ToolInfo wrapper is new, but its definition fields retain the objects
24
+ * registered by the extension. Keep identity checks for those fields: an exact
25
+ * foreign clone under the same name is still not ownership of our tool.
26
+ */
27
+ function isOurTool(
28
+ actual: PublishedTool,
29
+ expected: { description: string; parameters: unknown; promptGuidelines?: string[] },
30
+ ): boolean {
31
+ return actual.description === expected.description
32
+ && actual.parameters === expected.parameters
33
+ && actual.promptGuidelines === expected.promptGuidelines;
22
34
  }
23
35
  const HISTORY_ACTIONS = Object.keys(HISTORY_ENDPOINTS) as [HistoryAction, ...HistoryAction[]];
24
36
  const NOTES_ACTIONS = Object.keys(NOTES_ENDPOINTS) as [NotesAction, ...NotesAction[]];
@@ -182,6 +194,13 @@ export function createContextManagementTools(
182
194
  return { newContext, getContextRemaining, history, notes };
183
195
  }
184
196
 
197
+ export type ContextToolRegistrationState = "verified" | "conflict" | "unverified";
198
+
199
+ export type ContextToolSyncResult = {
200
+ synced: boolean;
201
+ registrationState: ContextToolRegistrationState;
202
+ };
203
+
185
204
  export class ContextManagementToolController {
186
205
  private readonly registeredNames = new Set<string>();
187
206
  private readonly definitions = new Map<string, {
@@ -190,8 +209,7 @@ export class ContextManagementToolController {
190
209
  promptGuidelines?: string[];
191
210
  }>();
192
211
  private registered = false;
193
- private registrationChecked = false;
194
- private registrationValid = false;
212
+ private verifiedOnce = false;
195
213
 
196
214
  constructor(private readonly pi: ExtensionAPI) {}
197
215
 
@@ -205,12 +223,16 @@ export class ContextManagementToolController {
205
223
  // those are bound only after extension loading completes.
206
224
  try {
207
225
  for (const definition of definitions) api.registerTool(definition);
226
+ this.registered = true;
208
227
  } catch {
228
+ // Pi rejects a registration whose name is already owned elsewhere.
229
+ this.registered = false;
209
230
  return false;
210
231
  }
211
232
 
212
233
  this.registeredNames.clear();
213
234
  this.definitions.clear();
235
+ this.verifiedOnce = false;
214
236
  for (const definition of definitions) {
215
237
  this.registeredNames.add(definition.name);
216
238
  this.definitions.set(definition.name, {
@@ -219,43 +241,58 @@ export class ContextManagementToolController {
219
241
  promptGuidelines: definition.promptGuidelines,
220
242
  });
221
243
  }
222
- this.registered = true;
223
- this.registrationChecked = false;
224
- this.registrationValid = false;
244
+ // A successful call only proves the hand-off; it does not prove the runtime
245
+ // published the definitions. `checkRegistration()` reads that from Pi.
225
246
  return true;
226
247
  }
227
248
 
228
- private verifyRegistration(): boolean {
229
- if (!this.registered) return false;
230
- if (this.registrationChecked) return this.registrationValid;
231
- this.registrationChecked = true;
249
+ /**
250
+ * Read registration state until the runtime confirms publication.
251
+ *
252
+ * Pi 0.86 can reject action methods while a session replacement is still
253
+ * binding, and can publish the registry after `session_start` ran. Negative
254
+ * reads remain retryable; only a successful verification is cached for this
255
+ * controller instance. A Pi reload creates a new controller.
256
+ */
257
+ checkRegistration(): ContextToolRegistrationState {
258
+ if (!this.registered) return "conflict";
259
+ // A confirmed publication is kept: the expensive part was never the read, it
260
+ // was trusting a failed one. Only negative verdicts are recomputed each time.
261
+ if (this.verifiedOnce) return "verified";
262
+ let available: PublishedTool[];
232
263
  try {
233
- const api = this.pi as ExtensionAPI & { getAllTools?: () => Array<{
234
- name: string;
235
- description: string;
236
- parameters: unknown;
237
- promptGuidelines?: string[];
238
- }> };
239
- if (typeof api.getAllTools !== "function") return false;
240
- const available = new Map(api.getAllTools().map((tool) => [tool.name, tool]));
241
- this.registrationValid = [...this.registeredNames].every((name) => {
242
- const actual = available.get(name);
243
- const expected = this.definitions.get(name);
244
- return actual !== undefined && expected !== undefined &&
245
- actual.description === expected.description &&
246
- actual.parameters === expected.parameters &&
247
- actual.promptGuidelines === expected.promptGuidelines;
248
- });
264
+ const api = this.pi as ExtensionAPI & { getAllTools?: () => PublishedTool[] };
265
+ if (typeof api.getAllTools !== "function") return "conflict";
266
+ available = api.getAllTools();
249
267
  } catch {
250
- this.registrationValid = false;
268
+ // Stale or unbound runtime: retry on the next sync.
269
+ return "unverified";
270
+ }
271
+ const byName = new Map(available.map((tool) => [tool.name, tool]));
272
+ let missing = false;
273
+ let conflict = false;
274
+ for (const name of this.registeredNames) {
275
+ const actual = byName.get(name);
276
+ const expected = this.definitions.get(name);
277
+ if (actual === undefined || expected === undefined) {
278
+ missing = true;
279
+ continue;
280
+ }
281
+ if (!isOurTool(actual, expected)) conflict = true;
251
282
  }
252
- return this.registrationValid;
283
+ if (conflict) return "conflict";
284
+ if (missing) return "unverified";
285
+ this.verifiedOnce = true;
286
+ return "verified";
253
287
  }
254
288
 
255
- sync(active: boolean): boolean {
256
- if (!this.verifyRegistration()) return false;
289
+ sync(active: boolean): ContextToolSyncResult {
290
+ const registrationState = this.checkRegistration();
291
+ if (registrationState !== "verified") return { synced: false, registrationState };
257
292
  const api = this.pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
258
- if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") return false;
293
+ if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") {
294
+ return { synced: false, registrationState };
295
+ }
259
296
  try {
260
297
  const current = api.getActiveTools();
261
298
  // Pi may activate a newly registered tool before the first sync. Once
@@ -265,16 +302,17 @@ export class ContextManagementToolController {
265
302
  ? [...current, ...[...this.registeredNames].filter((name) => !current.includes(name))]
266
303
  : current.filter((name) => !this.registeredNames.has(name));
267
304
  if (next.length !== current.length) api.setActiveTools(next);
268
- return true;
305
+ return { synced: true, registrationState };
269
306
  } catch {
270
- return false;
307
+ return { synced: false, registrationState };
271
308
  }
272
309
  }
273
310
 
274
311
  reset(): void {
275
312
  this.sync(false);
276
313
  }
277
- get isRegistered(): boolean { return this.registrationValid; }
314
+ get isRegistered(): boolean { return this.checkRegistration() === "verified"; }
315
+ get registrationState(): ContextToolRegistrationState { return this.checkRegistration(); }
278
316
  }
279
317
 
280
318
  export function registerContextManagementTools(
@@ -17,7 +17,10 @@ import {
17
17
  import { routeContextNamespaceToolMessage } from "./context-management/namespace-tools";
18
18
  import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
19
19
  import { CodexContextWindowManager } from "./context-management/window-manager";
20
- import { registerContextManagementTools } from "./context-management/tools";
20
+ import {
21
+ registerContextManagementTools,
22
+ type ContextToolRegistrationState,
23
+ } from "./context-management/tools";
21
24
  import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
22
25
  import {
23
26
  COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
@@ -88,6 +91,13 @@ type RemoteContextActive = (
88
91
  model?: ExtensionContext["model"],
89
92
  ) => Promise<boolean>;
90
93
 
94
+ type ContextToolSyncOutcome = {
95
+ eligible: boolean;
96
+ toolsSynced: boolean;
97
+ registrationState: ContextToolRegistrationState;
98
+ windowInitialized: boolean;
99
+ };
100
+
91
101
  type ResponsesCompactOutcome =
92
102
  | { outcome: "success"; compaction: CompactionResult<NativeCompactionDetails> }
93
103
  | { outcome: "aborted" }
@@ -1015,28 +1025,60 @@ export default function registerCompactionExtension(
1015
1025
  piContextHookPatch: overrides.piContextHookPatch ?? piContextHookPatch,
1016
1026
  };
1017
1027
  let tools!: ReturnType<typeof registerContextManagementTools>;
1028
+ let contextWindowReady = false;
1029
+ const isContextRuntimeActive = async (
1030
+ ctx: ExtensionContext,
1031
+ config: CompactionConfig,
1032
+ model = ctx.model,
1033
+ ): Promise<boolean> =>
1034
+ contextWindowReady && tools.isRegistered && await isRemoteContextActive(ctx, config, model);
1018
1035
  tools = registerContextManagementTools(
1019
1036
  pi,
1020
1037
  contextWindows,
1021
1038
  async (ctx) => {
1022
1039
  const config = dependencies.loadConfig().config.compaction;
1023
- return tools.isRegistered && await isRemoteContextActive(ctx, config);
1040
+ return isContextRuntimeActive(ctx, config);
1024
1041
  },
1025
1042
  () => dependencies.loadConfig().config.compaction.gatewayContextModels,
1026
1043
  );
1027
- const remoteContextActive: RemoteContextActive = async (ctx, config, model = ctx.model) =>
1028
- tools.isRegistered && await isRemoteContextActive(ctx, config, model);
1029
- const syncTools = async (ctx: ExtensionContext, model = ctx.model): Promise<boolean> => {
1044
+ const remoteContextActive: RemoteContextActive = isContextRuntimeActive;
1045
+ const syncTools = async (
1046
+ ctx: ExtensionContext,
1047
+ model = ctx.model,
1048
+ options: { notifyWindowFailure?: boolean } = {},
1049
+ ): Promise<ContextToolSyncOutcome> => {
1050
+ // Until this call proves otherwise, do not let a previous session/window
1051
+ // identity make a failed activation look usable.
1052
+ contextWindowReady = false;
1030
1053
  const config = dependencies.loadConfig().config.compaction;
1031
- const active = await isRemoteContextActive(ctx, config, model);
1032
- // sync() reports whether the tool-set update succeeded; an inactive model
1033
- // syncs fine and returns true. The activation decision must use `active`
1034
- // itself, or non-covered models would receive a window boundary.
1035
- const synced = tools.sync(active);
1036
- return active && synced;
1054
+ const eligible = await isRemoteContextActive(ctx, config, model);
1055
+ const toolSync = tools.sync(eligible);
1056
+ const outcome: ContextToolSyncOutcome = {
1057
+ eligible,
1058
+ toolsSynced: toolSync.synced,
1059
+ registrationState: toolSync.registrationState,
1060
+ windowInitialized: false,
1061
+ };
1062
+ if (!eligible || !toolSync.synced) return outcome;
1063
+ // Activation can succeed after `session_start` missed it, because Pi 0.86 may
1064
+ // reject the registration read while a session replacement is still binding.
1065
+ // The window lifecycle has to open on that later activation too, or requests
1066
+ // carry no window metadata and the backend never ingests the turns.
1067
+ try {
1068
+ contextWindows.ensureInitialized(pi, ctx, true);
1069
+ contextWindowReady = true;
1070
+ return { ...outcome, windowInitialized: true };
1071
+ } catch {
1072
+ // Do not leave tools exposed while the request path has no valid window
1073
+ // identity. The next lifecycle hook may retry once Pi is usable again.
1074
+ tools.sync(false);
1075
+ if (options.notifyWindowFailure) notifyRemoteContextFailure(ctx, "malformed-window-state");
1076
+ return outcome;
1077
+ }
1037
1078
  };
1038
1079
  pi.on("session_start", async (_event, ctx) => {
1039
- const active = await syncTools(ctx);
1080
+ const syncOutcome = await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
1081
+ const active = syncOutcome.eligible && syncOutcome.toolsSynced && syncOutcome.windowInitialized;
1040
1082
  const { config: toolkitConfig, source, warnings } = dependencies.loadConfig();
1041
1083
  const config = toolkitConfig.compaction;
1042
1084
  if (!config.enabled) return;
@@ -1044,16 +1086,16 @@ export default function registerCompactionExtension(
1044
1086
  let activationReason: string | undefined;
1045
1087
  // Only models the built-in Remote Context coverage targets may activate or
1046
1088
  // warn; everything else silently runs Pi's normal compaction path.
1047
- if (config.contextManagement === "remote" && isCodexContextModel(ctx.model, config)) {
1048
- if (active) {
1049
- try {
1050
- contextWindows.ensureInitialized(pi, ctx, true);
1051
- } catch {
1052
- notifyRemoteContextFailure(ctx, "malformed-window-state");
1053
- }
1054
- } else if (!tools.isRegistered) {
1089
+ if (config.contextManagement === "remote" && isCodexContextModel(ctx.model, config) && !active) {
1090
+ if (syncOutcome.registrationState === "conflict") {
1055
1091
  activationReason = "tool-name-conflict";
1056
1092
  notifyRemoteContextFailure(ctx, activationReason);
1093
+ } else if (syncOutcome.registrationState === "unverified" || !syncOutcome.toolsSynced) {
1094
+ // Transient: the runtime has not published or accepted our tools yet. A
1095
+ // later before_agent_start re-verifies, so do not report provider failure.
1096
+ activationReason = "tool-registration-pending";
1097
+ } else if (syncOutcome.eligible && !syncOutcome.windowInitialized) {
1098
+ activationReason = "malformed-window-state";
1057
1099
  } else {
1058
1100
  const remoteResolution = await resolveCodexContextProvider(ctx, ctx.model, config.gatewayContextModels);
1059
1101
  activationReason = remoteResolution.ok ? "codex-context-unavailable" : remoteResolution.reason;
@@ -1099,25 +1141,20 @@ export default function registerCompactionExtension(
1099
1141
  pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive));
1100
1142
  pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
1101
1143
  pi.on("session_shutdown", () => {
1144
+ contextWindowReady = false;
1102
1145
  contextWindows.reset();
1103
1146
  tools.reset();
1104
1147
  });
1105
1148
  pi.on("model_select", async (event, ctx) => {
1106
- const active = await syncTools(ctx, event.model);
1107
1149
  // Switching into a covered model mid-session must open the window
1108
1150
  // lifecycle immediately: without an identity the request rewrite skips
1109
1151
  // window metadata, the backend never ingests those turns, and the first
1110
1152
  // new_context would trim pre-switch history that no history can recover.
1111
- // ensureInitialized is idempotent when a window already exists.
1112
- if (!active) return;
1113
- try {
1114
- contextWindows.ensureInitialized(pi, ctx, true);
1115
- } catch {
1116
- notifyRemoteContextFailure(ctx, "malformed-window-state");
1117
- }
1153
+ // syncTools() activates and initializes the window when the model is covered.
1154
+ await syncTools(ctx, event.model, { notifyWindowFailure: true });
1118
1155
  });
1119
1156
  pi.on("before_agent_start", async (_event, ctx) => {
1120
- await syncTools(ctx);
1157
+ await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
1121
1158
  });
1122
1159
  pi.on("before_provider_request", (event, ctx) => handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive));
1123
1160
  pi.on("before_provider_headers", async (event, ctx) => {