pi-openai-toolkit 0.20.1 → 0.20.3

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/docs/internals.md CHANGED
@@ -45,6 +45,16 @@ Coverage is never inferred from model names. A native session uses provider `ope
45
45
 
46
46
  ---
47
47
 
48
+ ### Tool-selection boundary and unavailable Context Management
49
+
50
+ The four Toolkit context tools are `new_context`, `get_context_remaining`, `history`, and `notes`. The canonical names live in [src/context-management/tool-contract.ts](../src/context-management/tool-contract.ts). A launcher that creates a Pi session for an exact `compaction.gatewayContextModels` model should union those names into an explicit `tools` allowlist before `createAgentSession`, unless the caller selected `noTools`/`--no-tools`; an explicit `excludeTools` entry remains authoritative and is never re-added. This package does not contain the `pi-subagent` or child-session launcher source, so the helper and contract here do not claim to modify that external layer.
51
+
52
+ After registration, Pi's published registry is authoritative. Toolkit distinguishes `verified` (all four owned definitions), `excluded` (a readable registry contains none of them), `unverified` (a transient read or partial publication), and `conflict` (registration/API/ownership failure). Only `excluded` is a live-turn downgrade: `context_with_system` strips internal window markers, provider payload/header hooks return no-op results, and the lifecycle artifact records `context-tools-excluded`. Conflicts, unreadable/partial registries, malformed window state and missing gateway identity remain fail-closed for live provider requests.
53
+
54
+ If a covered Remote Context session reaches `session_before_compact` without an active context runtime, the first compaction path is the configured `compaction.remoteCompactModel` through Remote V2. The active session model remains the checkpoint consumer; the configured remote model is only the compactor. If that model is missing/unusable, only an explicitly configured/enabled `nativeFallback.model` may run. Otherwise the hook cancels rather than silently invoking the active model or Pi's default compaction. This compaction fallback does not weaken the live-provider safety rules above.
55
+
56
+ ---
57
+
48
58
  ### Rollover lifecycle
49
59
 
50
60
  The `new_context` happy path, end to end:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -13,8 +13,47 @@ export const NOTES_ACTIONS = [
13
13
  "write_file",
14
14
  ] as const;
15
15
 
16
+ /** Stable names shared by Toolkit registration and an external Pi launcher. */
17
+ export const CONTEXT_MANAGEMENT_TOOL_NAMES = [
18
+ "new_context",
19
+ "get_context_remaining",
20
+ "history",
21
+ "notes",
22
+ ] as const;
23
+
16
24
  export type HistoryAction = (typeof HISTORY_ACTIONS)[number];
17
25
  export type NotesAction = (typeof NOTES_ACTIONS)[number];
26
+ export type ContextManagementToolName = (typeof CONTEXT_MANAGEMENT_TOOL_NAMES)[number];
27
+
28
+ export type ContextToolSelection = {
29
+ /** Pi's explicit tool allowlist. Omitted means use Pi's default selection. */
30
+ tools?: readonly string[];
31
+ /** Pi's default suppression mode. Both values are explicit host policy. */
32
+ noTools?: "all" | "builtin";
33
+ /** Pi applies this denylist after the allowlist. */
34
+ excludeTools?: readonly string[];
35
+ };
36
+
37
+ /**
38
+ * Add Toolkit context tools to a host-provided allowlist without overriding an
39
+ * explicit no-tools or exclude-tools decision. The launcher owns model eligibility;
40
+ * this helper only owns the shared selection contract.
41
+ */
42
+ export function ensureContextManagementToolsInAllowlist(
43
+ selection: ContextToolSelection,
44
+ eligible: boolean,
45
+ ): string[] | undefined {
46
+ if (!eligible || selection.noTools !== undefined || selection.tools === undefined) {
47
+ return selection.tools === undefined ? undefined : [...selection.tools];
48
+ }
49
+
50
+ const excluded = new Set(selection.excludeTools ?? []);
51
+ const names = [...selection.tools];
52
+ for (const name of CONTEXT_MANAGEMENT_TOOL_NAMES) {
53
+ if (!excluded.has(name) && !names.includes(name)) names.push(name);
54
+ }
55
+ return names;
56
+ }
18
57
 
19
58
  export const HISTORY_DESCRIPTION =
20
59
  "Prior-window detail. Pass IDs unchanged. Search, never browse.";
@@ -119,6 +119,7 @@ export function createContextManagementTools(
119
119
  label: "new_context",
120
120
  description: "Start a new remote Codex context window without generating a conversation summary. Requires a persisted successful notes checkpoint in the current window.",
121
121
  parameters: NEW_CONTEXT_PARAMETERS,
122
+ exposure: "direct",
122
123
  promptSnippet: "Start a new remote Codex context window without summarizing history.",
123
124
  promptGuidelines: [
124
125
  "Before this new_context call, checkpoint active work in notes; only a persisted successful notes append/write in the current window unlocks this rollover, and no conversation summary carries over.",
@@ -169,6 +170,7 @@ export function createContextManagementTools(
169
170
  label: "get_context_remaining",
170
171
  description: "Get the remaining tokens in the current remote Codex context window.",
171
172
  parameters: EMPTY_PARAMETERS,
173
+ exposure: "direct",
172
174
  async execute(_id, _params, _signal, _update, ctx) {
173
175
  await assertActive(ctx);
174
176
  const remaining = manager.remaining(ctx);
@@ -183,6 +185,7 @@ export function createContextManagementTools(
183
185
  label: "history",
184
186
  description: "Search or read prior remote Codex context-window history. Pass IDs unchanged.",
185
187
  parameters: HISTORY_PARAMETERS,
188
+ exposure: "direct",
186
189
  promptSnippet: "Search or read prior remote Codex context-window history.",
187
190
  promptGuidelines: [
188
191
  "When the user asks about decisions, code, or details from earlier in the conversation that are no longer in the current context window, use history first instead of relying on fragments that survived compaction.",
@@ -201,6 +204,7 @@ export function createContextManagementTools(
201
204
  label: "notes",
202
205
  description: "Read and checkpoint remote Codex notes across context windows.",
203
206
  parameters: NOTES_PARAMETERS,
207
+ exposure: "direct",
204
208
  promptSnippet: "Read and checkpoint remote Codex notes across context windows.",
205
209
  promptGuidelines: [
206
210
  "Before calling new_context, checkpoint the current turn's active work (unfinished tasks, decisions, open questions, references) into notes with append_to_file or write_file so it survives the window change; wait for that result to be persisted before calling new_context.",
@@ -219,7 +223,7 @@ export function createContextManagementTools(
219
223
  return { newContext, getContextRemaining, history, notes };
220
224
  }
221
225
 
222
- export type ContextToolRegistrationState = "verified" | "conflict" | "unverified";
226
+ export type ContextToolRegistrationState = "verified" | "excluded" | "conflict" | "unverified";
223
227
 
224
228
  export type ContextToolSyncResult = {
225
229
  synced: boolean;
@@ -296,6 +300,7 @@ export class ContextManagementToolController {
296
300
  const byName = new Map(available.map((tool) => [tool.name, tool]));
297
301
  let missing = false;
298
302
  let conflict = false;
303
+ let published = 0;
299
304
  for (const name of this.registeredNames) {
300
305
  const actual = byName.get(name);
301
306
  const expected = this.definitions.get(name);
@@ -303,9 +308,13 @@ export class ContextManagementToolController {
303
308
  missing = true;
304
309
  continue;
305
310
  }
311
+ published += 1;
306
312
  if (!isOurTool(actual, expected)) conflict = true;
307
313
  }
308
314
  if (conflict) return "conflict";
315
+ // A readable registry with no Toolkit-owned definitions is the positive
316
+ // host-filtering signal used by --no-tools/restricted child sessions.
317
+ if (published === 0) return "excluded";
309
318
  if (missing) return "unverified";
310
319
  this.verifiedOnce = true;
311
320
  return "verified";
@@ -126,6 +126,7 @@ type RemoteContextActive = (
126
126
  config: CompactionConfig,
127
127
  model?: ExtensionContext["model"],
128
128
  ) => Promise<boolean>;
129
+ type ContextToolRegistrationStateReader = () => ContextToolRegistrationState;
129
130
 
130
131
  type ContextToolSyncOutcome = {
131
132
  eligible: boolean;
@@ -211,6 +212,27 @@ function isCodexContextModel(
211
212
  && (isNativeCodexModel(model) || isCodexGatewayModel(model, config.gatewayContextModels));
212
213
  }
213
214
 
215
+ function isExcludedGatewayContext(
216
+ model: ExtensionContext["model"] | undefined,
217
+ config: CompactionConfig,
218
+ registrationState: ContextToolRegistrationState,
219
+ ): boolean {
220
+ return registrationState === "excluded" && isCodexGatewayModel(model, config.gatewayContextModels);
221
+ }
222
+
223
+ function contextManagementFailureReason(registrationState: ContextToolRegistrationState): string {
224
+ switch (registrationState) {
225
+ case "excluded":
226
+ return "context-tools-excluded";
227
+ case "conflict":
228
+ return "tool-name-conflict";
229
+ case "unverified":
230
+ return "tool-registration-pending";
231
+ case "verified":
232
+ return "context-management-unavailable";
233
+ }
234
+ }
235
+
214
236
  function notifyRemoteContextFailure(ctx: ExtensionContext, reason: string): void {
215
237
  if (ctx.hasUI) {
216
238
  ctx.ui.notify(`${COMPACTION_EXTENSION_ID}: Remote Context management inactive (${reason})`, "warning");
@@ -567,11 +589,140 @@ async function runResponsesNativeCompact(
567
589
  return { outcome: "success", compaction };
568
590
  }
569
591
 
592
+ type ContextUnavailableCompactionResult =
593
+ | { compaction: CompactionResult<unknown> }
594
+ | { cancel: true };
595
+
596
+ async function runUnavailableContextCompaction(
597
+ event: SessionBeforeCompactEvent,
598
+ ctx: ExtensionContext,
599
+ dependencies: CompactionDependencies,
600
+ resolved: ResolvedToolkitConfig,
601
+ config: CompactionConfig,
602
+ registrationState: ContextToolRegistrationState,
603
+ ): Promise<ContextUnavailableCompactionResult> {
604
+ const contextReason = contextManagementFailureReason(registrationState);
605
+ const remoteModelSpec = config.remoteCompactModel?.trim();
606
+ let remoteFailure: string | undefined;
607
+
608
+ if (remoteModelSpec) {
609
+ const resolution = await resolveRemoteCompactionExecution(
610
+ ctx,
611
+ {
612
+ enabled: config.enabled,
613
+ responsesApis: config.responsesApis,
614
+ codexGatewayModels: compactGatewayModels(resolved),
615
+ },
616
+ remoteModelSpec,
617
+ );
618
+ if (resolution.ok) {
619
+ const outcome = await runResponsesNativeCompact(
620
+ event,
621
+ ctx,
622
+ config,
623
+ resolution.execution,
624
+ dependencies.remoteCompact,
625
+ dependencies.projectCompactionContext,
626
+ );
627
+ if (outcome.outcome === "success") {
628
+ writeDebugArtifact(
629
+ "compaction-event",
630
+ {
631
+ event: "session_before_compact.context-fallback-success",
632
+ contextReason,
633
+ registrationState,
634
+ compactor: remoteModelSpec,
635
+ },
636
+ config,
637
+ ctx,
638
+ );
639
+ return { compaction: outcome.compaction };
640
+ }
641
+ if (outcome.outcome === "aborted" || outcome.outcome === "unprojected-input") {
642
+ return { cancel: true };
643
+ }
644
+ remoteFailure = "remote-v2-failed";
645
+ } else {
646
+ remoteFailure = resolution.reason;
647
+ writeDebugArtifact(
648
+ "compaction-event",
649
+ {
650
+ event: "session_before_compact.context-fallback-unavailable",
651
+ contextReason,
652
+ registrationState,
653
+ reason: resolution.reason,
654
+ modelSpec: remoteModelSpec,
655
+ errorMessage: resolution.errorMessage,
656
+ },
657
+ config,
658
+ ctx,
659
+ );
660
+ }
661
+ } else {
662
+ remoteFailure = "missing-remote-compaction-model";
663
+ }
664
+
665
+ const nativeModelSpec = config.nativeFallback.model?.trim();
666
+ if (config.nativeFallback.enabled && nativeModelSpec) {
667
+ const fallback = await dependencies.nativeFallback({
668
+ ctx,
669
+ event,
670
+ config,
671
+ modelSpec: nativeModelSpec,
672
+ });
673
+ if (fallback.ok) {
674
+ writeDebugArtifact(
675
+ "compaction-event",
676
+ {
677
+ event: "session_before_compact.context-fallback-native-success",
678
+ contextReason,
679
+ registrationState,
680
+ remoteFailure,
681
+ fallbackModel: nativeModelSpec,
682
+ },
683
+ config,
684
+ ctx,
685
+ );
686
+ return { compaction: fallback.result };
687
+ }
688
+ writeDebugArtifact(
689
+ "compaction-event",
690
+ {
691
+ event: "session_before_compact.context-fallback-native-failure",
692
+ contextReason,
693
+ registrationState,
694
+ remoteFailure,
695
+ fallbackReason: fallback.reason,
696
+ fallbackModel: nativeModelSpec,
697
+ errorMessage: fallback.errorMessage,
698
+ },
699
+ config,
700
+ ctx,
701
+ );
702
+ } else {
703
+ writeDebugArtifact(
704
+ "compaction-event",
705
+ {
706
+ event: "session_before_compact.context-fallback-cancelled",
707
+ contextReason,
708
+ registrationState,
709
+ remoteFailure,
710
+ reason: config.nativeFallback.enabled ? "missing-native-fallback-model" : "native-fallback-disabled",
711
+ },
712
+ config,
713
+ ctx,
714
+ );
715
+ }
716
+
717
+ return { cancel: true };
718
+ }
719
+
570
720
  async function handleSessionBeforeCompact(
571
721
  event: SessionBeforeCompactEvent,
572
722
  ctx: ExtensionContext,
573
723
  dependencies: CompactionDependencies,
574
724
  remoteContextActive: RemoteContextActive,
725
+ getContextToolRegistrationState: ContextToolRegistrationStateReader,
575
726
  ) {
576
727
  const resolved = contextOperation(dependencies.loadConfig, ctx);
577
728
  if (resolved.invalidFeatures.some((feature) => ["context", "compatibility", "diagnostics"].includes(feature))) return { cancel: true };
@@ -602,7 +753,8 @@ async function handleSessionBeforeCompact(
602
753
  }
603
754
 
604
755
  // Remote Context management owns this eligible Codex session. It persists a
605
- // no-summary boundary and deliberately never calls remote_compaction_v2.
756
+ // no-summary boundary while active; an unavailable runtime uses the configured
757
+ // Remote V2 compactor instead of silently returning to Pi's active-model path.
606
758
  const contextModel = await resolveContextCapabilityModel(ctx);
607
759
  if (isCodexContextModel(contextModel, config)) {
608
760
  if (await remoteContextActive(ctx, config, contextModel)) {
@@ -618,14 +770,20 @@ async function handleSessionBeforeCompact(
618
770
  return { cancel: true };
619
771
  }
620
772
  }
621
- // A configured native Codex Remote session must never re-enter this
622
- // extension's Remote V2 compaction chain, and Pi's native compaction is
623
- // deliberately disabled for Remote-managed models: cancelling here keeps
624
- // the boundary the only rollover mechanism. The inactive reason is
625
- // surfaced so the user can fix activation instead of losing context to a
626
- // silent native summary.
627
- notifyRemoteContextFailure(ctx, "native-codex-context-unavailable");
628
- return { cancel: true };
773
+
774
+ const registrationState = getContextToolRegistrationState();
775
+ const failureReason = contextManagementFailureReason(registrationState);
776
+ if (failureReason !== "context-tools-excluded") {
777
+ notifyRemoteContextFailure(ctx, failureReason);
778
+ }
779
+ return runUnavailableContextCompaction(
780
+ event,
781
+ ctx,
782
+ dependencies,
783
+ resolved,
784
+ config,
785
+ registrationState,
786
+ );
629
787
  }
630
788
 
631
789
  // Resolve producer protocol policy from this operation's document, not a later disk read.
@@ -865,6 +1023,7 @@ async function handleContextInternal(
865
1023
  loadConfig: typeof loadToolkitConfig,
866
1024
  contextWindows: CodexContextWindowManager,
867
1025
  remoteContextActive: RemoteContextActive,
1026
+ getContextToolRegistrationState: ContextToolRegistrationStateReader,
868
1027
  ) {
869
1028
  const resolved = contextOperation(loadConfig, ctx);
870
1029
  requireContextPolicy(resolved, ctx);
@@ -913,13 +1072,21 @@ async function handleContextInternal(
913
1072
  }
914
1073
 
915
1074
  // An eligible Codex Remote session is never allowed to fall through to the
916
- // older compaction-replay path. Gateway traffic must also fail closed when
917
- // its transport capability is unavailable; otherwise Pi could send an
918
- // ordinary unscoped request and lose CPA OAuth/session affinity.
1075
+ // older compaction-replay path. An explicit host exclusion is the one
1076
+ // intentional downgrade: strip internal markers and let the ordinary request
1077
+ // proceed without Remote Context affinity.
919
1078
  if (isCodexContextModel(contextModel, config)) {
1079
+ const registrationState = getContextToolRegistrationState();
1080
+ if (isExcludedGatewayContext(contextModel, config, registrationState)) {
1081
+ const visibleMessages = contextWindows.project(event.messages, "off");
1082
+ return visibleMessages.length === event.messages.length && visibleMessages.every((message, index) => message === event.messages[index])
1083
+ ? undefined
1084
+ : { messages: visibleMessages };
1085
+ }
920
1086
  if (isCodexGatewayModel(contextModel, config.gatewayContextModels)) {
921
- notifyRemoteContextFailure(ctx, "codex-context-unavailable");
922
- reportPiContextHookFailure(ctx, "codex-context-unavailable");
1087
+ const reason = contextManagementFailureReason(registrationState);
1088
+ notifyRemoteContextFailure(ctx, reason);
1089
+ reportPiContextHookFailure(ctx, reason);
923
1090
  ctx.abort();
924
1091
  return undefined;
925
1092
  }
@@ -972,9 +1139,10 @@ async function handleContext(
972
1139
  loadConfig: typeof loadToolkitConfig,
973
1140
  contextWindows: CodexContextWindowManager,
974
1141
  remoteContextActive: RemoteContextActive,
1142
+ getContextToolRegistrationState: ContextToolRegistrationStateReader,
975
1143
  ) {
976
1144
  try {
977
- return await handleContextInternal(event, ctx, pi, loadConfig, contextWindows, remoteContextActive);
1145
+ return await handleContextInternal(event, ctx, pi, loadConfig, contextWindows, remoteContextActive, getContextToolRegistrationState);
978
1146
  } catch (error) {
979
1147
  reportPiContextHookFailure(ctx, error instanceof Error ? error.message : String(error));
980
1148
  throw error;
@@ -987,6 +1155,7 @@ async function handleBeforeProviderRequest(
987
1155
  loadConfig: typeof loadToolkitConfig,
988
1156
  contextWindows: CodexContextWindowManager,
989
1157
  remoteContextActive: RemoteContextActive,
1158
+ getContextToolRegistrationState: ContextToolRegistrationStateReader,
990
1159
  ) {
991
1160
  const resolved = contextOperation(loadConfig, ctx);
992
1161
  requireContextPolicy(resolved, ctx);
@@ -1018,12 +1187,17 @@ async function handleBeforeProviderRequest(
1018
1187
  }
1019
1188
  }
1020
1189
  if (isCodexContextModel(contextModel, config)) {
1190
+ const registrationState = getContextToolRegistrationState();
1191
+ // A readable empty registry is an intentional host exclusion. Do not
1192
+ // rewrite gateway identity or abort an ordinary child-session request.
1193
+ if (isExcludedGatewayContext(contextModel, config, registrationState)) return undefined;
1021
1194
  // Keep Codex Remote mutually exclusive with the legacy replay pipeline
1022
1195
  // when authentication or tool ownership is unavailable. Gateway traffic
1023
1196
  // cannot continue as an ordinary Responses request because that would
1024
1197
  // silently lose the required CPA session/account selection.
1025
1198
  if (isCodexGatewayModel(contextModel, config.gatewayContextModels)) {
1026
- notifyRemoteContextFailure(ctx, "codex-context-unavailable");
1199
+ const reason = contextManagementFailureReason(registrationState);
1200
+ notifyRemoteContextFailure(ctx, reason);
1027
1201
  ctx.abort();
1028
1202
  }
1029
1203
  return undefined;
@@ -1278,7 +1452,9 @@ export default function registerCompactionExtension(
1278
1452
  // Only models the built-in Remote Context coverage targets may activate or
1279
1453
  // warn; everything else silently runs Pi's normal compaction path.
1280
1454
  if (config.contextManagement === "remote" && isCodexContextModel(contextModel, config) && !active) {
1281
- if (syncOutcome.registrationState === "conflict") {
1455
+ if (syncOutcome.registrationState === "excluded") {
1456
+ activationReason = "context-tools-excluded";
1457
+ } else if (syncOutcome.registrationState === "conflict") {
1282
1458
  activationReason = "tool-name-conflict";
1283
1459
  notifyRemoteContextFailure(ctx, activationReason);
1284
1460
  } else if (syncOutcome.registrationState === "unverified" || !syncOutcome.toolsSynced) {
@@ -1325,11 +1501,12 @@ export default function registerCompactionExtension(
1325
1501
  }
1326
1502
  });
1327
1503
 
1504
+ const getContextToolRegistrationState: ContextToolRegistrationStateReader = () => tools.registrationState;
1328
1505
  pi.on("context_with_system", (event, ctx) => {
1329
1506
  manualCompact.guardRequest(ctx);
1330
- return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive);
1507
+ return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive, getContextToolRegistrationState);
1331
1508
  });
1332
- pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive));
1509
+ pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive, getContextToolRegistrationState));
1333
1510
  pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
1334
1511
  pi.on("session_compact_failed", async (event, ctx) => {
1335
1512
  if (event.reason === "manual" && !manualCompact.isMaintenance) await manualCompact.compactFailed(ctx);
@@ -1373,7 +1550,7 @@ export default function registerCompactionExtension(
1373
1550
  });
1374
1551
  pi.on("before_provider_request", (event, ctx) => {
1375
1552
  manualCompact.guardRequest(ctx);
1376
- return handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive);
1553
+ return handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive, getContextToolRegistrationState);
1377
1554
  });
1378
1555
  pi.on("before_provider_headers", async (event, ctx) => {
1379
1556
  const resolved = contextOperation(dependencies.loadConfig, ctx);
@@ -1383,8 +1560,11 @@ export default function registerCompactionExtension(
1383
1560
  if (!isCodexContextModel(contextModel, config)) return;
1384
1561
  const active = await remoteContextActive(ctx, config, contextModel);
1385
1562
  if (!active) {
1563
+ const registrationState = getContextToolRegistrationState();
1564
+ if (isExcludedGatewayContext(contextModel, config, registrationState)) return;
1386
1565
  if (isCodexGatewayModel(contextModel, config.gatewayContextModels)) {
1387
- notifyRemoteContextFailure(ctx, "codex-context-unavailable");
1566
+ const reason = contextManagementFailureReason(registrationState);
1567
+ notifyRemoteContextFailure(ctx, reason);
1388
1568
  ctx.abort();
1389
1569
  }
1390
1570
  return;