pi-openai-toolkit 0.6.0 → 0.7.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # pi-openai-toolkit
2
2
 
3
3
  <p align="center">
4
- <strong>OpenAI-focused toolkit for <a href="https://github.com/badlogic/pi">Pi</a>: Native Remote Compaction v2 & Hosted Web Search</strong>
4
+ <strong>OpenAI-focused toolkit for <a href="https://github.com/earendil-works/pi">Pi</a>: Native Remote Compaction v2 & Hosted Web Search</strong>
5
5
  </p>
6
6
 
7
7
  <p align="center">
@@ -87,6 +87,11 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
87
87
  "remoteCompactModel": "uwoacrimson/gpt-5.6-luna",
88
88
  "model": null,
89
89
  "thinkingLevel": "off",
90
+ "autoCompaction": {
91
+ "enabled": true,
92
+ "continuation": "inline",
93
+ "unsupportedFallback": "followUp"
94
+ },
90
95
  "responsesApis": [
91
96
  "openai-responses",
92
97
  "openai-codex-responses"
@@ -113,9 +118,13 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
113
118
 
114
119
  - `enabled` (*boolean*, default: `true`): Enable or disable Remote Compaction.
115
120
  - `allowCompactionContinuityBreak` (*boolean*, default: `false`): When `true`, allows restarting a fresh native opaque compaction chain from Pi's current session text if the latest compaction was made by another strategy (e.g. text summary).
116
- - `remoteCompactModel` (*string | null*, default: `null`): Model formatted as `provider/model-id` used only for the synthetic `remote_compaction_v2` request. Pi's active session model is not switched and resumes normal requests after compaction. The override must resolve to the same provider, Responses API identifier, and effective base URL as the active model. `null` preserves the current same-model path.
121
+ - `remoteCompactModel` (*string | null*, default: `null`): Model formatted as `provider/model-id` used only for the synthetic `remote_compaction_v2` request. Pi's active session model is not switched and resumes normal requests after compaction. The override must resolve to the same effective base URL as the active model; provider, Responses API identifier, and model may differ. `null` preserves the current same-model path.
117
122
  - `model` (*string | null*, default: `null`): Separate native text-summary fallback model formatted as `provider/model-id` (e.g. `"openai/gpt-4o-mini"`) used if remote v2 is unavailable or fails. If `null`, Pi uses its default current-model compaction path.
118
123
  - `thinkingLevel` (*string*, default: `"off"`): Reasoning / thinking level passed to Pi native compaction fallback (`"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`).
124
+ - `autoCompaction.enabled` (*boolean*, default: `true`): Check for compaction after a completed tool-result batch crosses Pi's context threshold.
125
+ - `autoCompaction.continuation` (*`"inline" | "followUp" | "off"`*, default: `"inline"`): `inline` compacts inside the current official Pi 0.84.3 tool loop and refreshes the next provider context; `followUp` uses Pi's public hidden follow-up message path; `off` disables toolkit-triggered automatic compaction.
126
+ - `autoCompaction.unsupportedFallback` (*`"followUp" | "off"`*, default: `"followUp"`): Behavior when the guarded official-host inline adapter is unavailable. It does not change normal remote/native compaction fallback ordering.
127
+ - `autoCompaction.reserveTokens` (*non-negative integer*, optional): Override only the automatic trigger reserve. When omitted, the active Pi compaction setting is used. Pi's `keepRecentTokens` remains authoritative for compactability.
119
128
  - `responsesApis` (*string[]*): Narrow the supported remote compaction API identifiers (subset of `["openai-responses", "openai-codex-responses"]`).
120
129
  - `notifyOnLoad` (*boolean*, default: `false`): Show a notification banner on Pi startup.
121
130
  - `debug` (*boolean*, default: `false`): Output diagnostics and write execution artifacts to disk.
@@ -154,12 +163,22 @@ Content-Type: application/json
154
163
  - **Validation Criteria**: Must return a valid SSE stream with `response.completed` (status `completed`) containing exactly one output item of `type: "compaction"` with non-empty `encrypted_content`.
155
164
  - **Remote Model Override**: `compaction.remoteCompactModel` can assign a same-gateway Responses model as the checkpoint producer while the active model remains the consumer. Its registered model metadata, authentication, headers, and effective base URL are used only for the synthetic compact request. Existing and recursive checkpoints remain replayable by the active model.
156
165
  - **Zero Loss Replay**: The opaque encrypted item is stored in `CompactionEntry.details.compactedWindow`. On subsequent requests, it is transparently prepended after fresh preamble instructions alongside live turns, eliminating lossy text re-summaries.
166
+ - **Fail-Closed Replay Guard**: Replay verifies only the compaction summary sentinel plus the kept pre-compaction window derived from session entries; leading/trailing prompts and the post-compaction live tail are preserved verbatim, so transient messages injected by other extensions no longer block replay. If the kept region cannot be verified (e.g. `expected-pi-replay-mismatch` or `compaction-summary-not-found`), the toolkit aborts the provider request, shows an error notification, and always writes a content-free `*-replay-failure.json` artifact even when `logProviderPayloads` is disabled. The sentinel-only payload is never sent to the model.
157
167
  - **Graceful Multi-Tier Fallback**:
158
168
  1. Compatible Responses endpoints &rarr; Remote v2 Opaque Compaction.
159
169
  2. Gateway failure or non-Responses model &rarr; Configured `compaction.model` fallback.
160
170
  3. Otherwise &rarr; Pi default native text compaction.
161
171
 
162
- ### 2. Model-Scoped Web Search
172
+ ### 2. Tool-Loop Auto-Compaction and Continuation
173
+
174
+ After every complete tool-call/result batch, the toolkit checks the current Pi usage threshold. It then mirrors official Pi's `prepareCompaction()` eligibility using Pi's exported cut-point and session-entry helpers before invoking the normal `AgentSession.compact()` path.
175
+
176
+ - **Inline mode**: On the guarded official Pi `0.84.3` host shape, compaction finishes before the next provider request and `prepareNextTurnWithContext` receives the newly built Pi session context. Remote v2, native fallback, replay rewriting, hook order, and deferred-tool carryover still run through their existing owners.
177
+ - **Too-small sessions**: Crossing the token threshold does not prove there is discardable history. If Pi has nothing to summarize, the toolkit does not call `compact()`, does not abort the active run, and lets the unfinished tool loop continue normally.
178
+ - **Public fallback**: `followUp` uses `ExtensionAPI.sendMessage` with a hidden `followUp` message after compaction. The turn is handled once, preventing repeated compaction/follow-up storms.
179
+ - **Version guard**: The private same-loop adapter is enabled only for the structurally verified official Pi `0.84.3` `AgentSession`. Unsupported hosts use the configured public fallback or disable automatic continuation.
180
+
181
+ ### 3. Model-Scoped Web Search
163
182
 
164
183
  For allowlisted models using `openai-responses` or `openai-codex-responses`, the extension hooks into `before_provider_request` and `before_agent_start`:
165
184
 
package/README_zh.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # pi-openai-toolkit
2
2
 
3
3
  <p align="center">
4
- <strong>专为 <a href="https://github.com/badlogic/pi">Pi</a> 打造的 OpenAI 全功能增强工具包:远端无损压缩 v2 & 原生托管联网搜索</strong>
4
+ <strong>专为 <a href="https://github.com/earendil-works/pi">Pi</a> 打造的 OpenAI 全功能增强工具包:远端无损压缩 v2 & 原生托管联网搜索</strong>
5
5
  </p>
6
6
 
7
7
  <p align="center">
@@ -87,6 +87,11 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
87
87
  "remoteCompactModel": "uwoacrimson/gpt-5.6-luna",
88
88
  "model": null,
89
89
  "thinkingLevel": "off",
90
+ "autoCompaction": {
91
+ "enabled": true,
92
+ "continuation": "inline",
93
+ "unsupportedFallback": "followUp"
94
+ },
90
95
  "responsesApis": [
91
96
  "openai-responses",
92
97
  "openai-codex-responses"
@@ -113,9 +118,13 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
113
118
 
114
119
  - `enabled` (*boolean*, 默认 `true`): 是否启用远端无损压缩。
115
120
  - `allowCompactionContinuityBreak` (*boolean*, 默认 `false`): 若最近一次历史压缩由其他方式(如文本摘要)生成,设为 `true` 允许直接从当前有效文本上下文重建密文压缩链。
116
- - `remoteCompactModel` (*string | null*, 默认 `null`): 仅用于 synthetic `remote_compaction_v2` 请求的模型,格式为 `"provider/model-id"`。Pi 不会切换当前会话模型,压缩完成后仍由原模型继续正常请求。覆盖模型必须与当前模型使用相同的 Provider、Responses API 标识符和实际生效的 Base URL。为 `null` 时保持当前模型执行远端压缩的原有行为。
121
+ - `remoteCompactModel` (*string | null*, 默认 `null`): 仅用于 synthetic `remote_compaction_v2` 请求的模型,格式为 `"provider/model-id"`。Pi 不会切换当前会话模型,压缩完成后仍由原模型继续正常请求。覆盖模型必须与当前模型使用相同的实际生效 Base URL;Provider、Responses API 标识符和模型本身可以不同。为 `null` 时保持当前模型执行远端压缩的原有行为。
117
122
  - `model` (*string | null*, 默认 `null`): 独立的原生文本摘要降级模型,格式为 `"provider/model-id"`(例如 `"openai/gpt-4o-mini"`)。当 Remote v2 不可用或失败时使用;为 `null` 时进入 Pi 默认的当前模型压缩路径。
118
123
  - `thinkingLevel` (*string*, 默认 `"off"`): 降级模型调用 Pi 原生压缩时的思考级别(`"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`)。
124
+ - `autoCompaction.enabled` (*boolean*, 默认 `true`): 完整工具调用/结果批次结束并越过 Pi 上下文阈值后,检查是否需要压缩。
125
+ - `autoCompaction.continuation` (*`"inline" | "followUp" | "off"`*, 默认 `"inline"`): `inline` 在官方 Pi 0.84.3 当前工具循环内完成压缩并刷新下一次 Provider 上下文;`followUp` 使用 Pi 公共的隐藏后续消息;`off` 关闭 Toolkit 触发的自动压缩。
126
+ - `autoCompaction.unsupportedFallback` (*`"followUp" | "off"`*, 默认 `"followUp"`): 官方 Host 私有 inline 适配器不可用时的处理方式,不改变原有远端/原生压缩降级顺序。
127
+ - `autoCompaction.reserveTokens` (*非负整数*, 可选): 仅覆盖自动触发阈值的保留 Token 数。未设置时使用当前 Pi 压缩配置;是否具有可压缩历史仍以 Pi 的 `keepRecentTokens` 为准。
119
128
  - `responsesApis` (*string[]*): 允许尝试远端压缩的 API 标识符子集(从 `["openai-responses", "openai-codex-responses"]` 中选定)。
120
129
  - `notifyOnLoad` (*boolean*, 默认 `false`): Pi 启动加载时是否弹出气泡提示。
121
130
  - `debug` (*boolean*, 默认 `false`): 是否开启调试模式并记录诊断日志。
@@ -154,12 +163,22 @@ Content-Type: application/json
154
163
  - **校验标准**:接收 SSE 流并验证 `response.completed` 事件(状态为 `completed`),且返回且仅返回一个包含非空 `encrypted_content` 密文的 `type: "compaction"` 数据块。
155
164
  - **远端压缩模型覆盖**:`compaction.remoteCompactModel` 可以让同一网关上的另一个 Responses 模型负责生成 checkpoint,而当前活动模型仍是 checkpoint 的消费者。synthetic 压缩请求会使用覆盖模型在注册表中的模型元数据、认证、请求头和实际 Base URL;旧 checkpoint、递归压缩 checkpoint 都仍由当前活动模型重放。
156
165
  - **无损重放机制**:密文数据完整存入 `CompactionEntry.details.compactedWindow`。在后续对话中,插件会自动将密文插入在最新的系统指令之后与最新几轮消息之前,实现完全服务端无损上下文还原。
166
+ - **重放失败保护(Fail-Closed)**:重放只校验 compaction summary 哨兵项以及由 session entries 推导出的压缩前保留窗口;前置/后置 prompt 与压缩后的实时尾部消息一律原样保留,因此其他扩展注入的临时消息不再阻断重放。若保留窗口无法与 session entries 对齐(如 `expected-pi-replay-mismatch`、`compaction-summary-not-found`),插件会中止 provider 请求、弹出错误通知,并且即使 `logProviderPayloads` 关闭也会写入一份不含内容的安全失败记录 `*-replay-failure.json`。只含哨兵占位符的请求体永远不会发送给模型。
157
167
  - **三级优雅降级**:
158
168
  1. 支持的 Responses 端点 &rarr; 触发 Remote v2 密文无损压缩;
159
169
  2. 网关不支持或报错 &rarr; 降级调用用户指定的 `compaction.model`;
160
170
  3. 未配置降级模型 &rarr; 回退至 Pi 默认的当前模型摘要压缩。
161
171
 
162
- ### 2. 按模型启用的联网搜索
172
+ ### 2. 工具循环内自动压缩与续跑
173
+
174
+ 每个完整工具调用/结果批次结束后,Toolkit 会检查当前 Pi 上下文阈值。真正调用压缩前,它使用 Pi 导出的 cut-point 与 session-entry helper 复现官方 `prepareCompaction()` 的可压缩性门禁,然后仍通过正常的 `AgentSession.compact()` 路径执行。
175
+
176
+ - **Inline 模式**:仅在结构校验通过的官方 Pi `0.84.3` Host 上启用。压缩会在下一次 Provider 请求前完成,`prepareNextTurnWithContext` 使用 Pi 重建后的最新会话上下文。Remote v2、原生降级、密文重放、Hook 顺序和 deferred-tool carryover 仍由原有实现负责。
177
+ - **历史过短**:越过 Token 阈值不代表一定存在可丢弃历史。若 Pi 没有可摘要内容,Toolkit 不调用 `compact()`、不终止当前运行,让未完成的工具循环正常继续。
178
+ - **公共降级**:`followUp` 通过 `ExtensionAPI.sendMessage` 发送隐藏的后续消息,并对同一工具批次只处理一次,避免重复压缩或消息风暴。
179
+ - **版本门禁**:私有同循环适配器仅支持结构验证通过的官方 Pi `0.84.3` `AgentSession`。其他 Host 按配置使用公共降级或关闭自动续跑。
180
+
181
+ ### 3. 按模型启用的联网搜索
163
182
 
164
183
  对于白名单中的 `openai-responses` 或 `openai-codex-responses` 模型,扩展直接拦截请求载荷:
165
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "OpenAI-focused Pi toolkit providing remote Responses compaction and native Web Search.",
@@ -40,6 +40,8 @@
40
40
  "src/config.ts",
41
41
  "src/debug.ts",
42
42
  "src/details-store.ts",
43
+ "src/deferred-tool-carryover.ts",
44
+ "src/inline-compaction.ts",
43
45
  "src/extension-runtime.ts",
44
46
  "src/native-fallback.ts",
45
47
  "src/payload-rewrite.ts",
@@ -72,6 +74,9 @@
72
74
  "@earendil-works/pi-coding-agent": ">=0.80.0"
73
75
  },
74
76
  "devDependencies": {
77
+ "@earendil-works/pi-agent-core": "0.84.3",
78
+ "@earendil-works/pi-ai": "0.84.3",
79
+ "@earendil-works/pi-coding-agent": "0.84.3",
75
80
  "@types/node": "24.12.4",
76
81
  "bun": "1.2.23",
77
82
  "typescript": "5.9.3"
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
5
  import {
6
6
  DEFAULT_COMPACTION_CONFIG,
7
7
  DEFAULT_TOOLKIT_CONFIG,
8
+ DEFAULT_AUTO_COMPACTION_CONFIG,
8
9
  DEFAULT_WEB_SEARCH_CONFIG,
9
10
  RESPONSES_COMPACT_CAPABLE_APIS,
10
11
  THINKING_LEVELS,
@@ -25,6 +26,7 @@ const COMPACTION_FIELDS = new Set([
25
26
  "remoteCompactModel",
26
27
  "model",
27
28
  "thinkingLevel",
29
+ "autoCompaction",
28
30
  "responsesApis",
29
31
  "notifyOnLoad",
30
32
  "debug",
@@ -33,6 +35,7 @@ const COMPACTION_FIELDS = new Set([
33
35
  "redactSensitiveData",
34
36
  "artifactRoot",
35
37
  ]);
38
+ const AUTO_COMPACTION_FIELDS = new Set(["enabled", "continuation", "unsupportedFallback", "reserveTokens"]);
36
39
  const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
37
40
 
38
41
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -111,6 +114,37 @@ function toThinkingLevel(value: unknown, fieldPath: string, warnings: string[]):
111
114
  return undefined;
112
115
  }
113
116
 
117
+ function toCompactionContinuation(
118
+ value: unknown,
119
+ fieldPath: string,
120
+ warnings: string[],
121
+ ): "inline" | "followUp" | "off" | undefined {
122
+ if (value === undefined) return undefined;
123
+ if (value === "inline" || value === "followUp" || value === "off") return value;
124
+ warnings.push(`Ignoring ${fieldPath}: expected one of inline, followUp, off.`);
125
+ return undefined;
126
+ }
127
+
128
+ function toUnsupportedFallback(
129
+ value: unknown,
130
+ fieldPath: string,
131
+ warnings: string[],
132
+ ): "followUp" | "off" | undefined {
133
+ if (value === undefined) return undefined;
134
+ if (value === "followUp" || value === "off") return value;
135
+ warnings.push(`Ignoring ${fieldPath}: expected one of followUp, off.`);
136
+ return undefined;
137
+ }
138
+
139
+ function toNonNegativeInteger(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
140
+ if (value === undefined) return undefined;
141
+ if (typeof value === "number" && Number.isInteger(value) && Number.isFinite(value) && value >= 0) {
142
+ return value;
143
+ }
144
+ warnings.push(`Ignoring ${fieldPath}: expected a non-negative integer.`);
145
+ return undefined;
146
+ }
147
+
114
148
  function toSupportedApis(
115
149
  value: unknown,
116
150
  fieldPath: string,
@@ -152,6 +186,7 @@ function cloneDefaults(): ToolkitConfig {
152
186
  return {
153
187
  compaction: {
154
188
  ...DEFAULT_COMPACTION_CONFIG,
189
+ autoCompaction: { ...DEFAULT_AUTO_COMPACTION_CONFIG },
155
190
  responsesApis: [...DEFAULT_COMPACTION_CONFIG.responsesApis],
156
191
  },
157
192
  webSearch: {
@@ -161,6 +196,32 @@ function cloneDefaults(): ToolkitConfig {
161
196
  };
162
197
  }
163
198
 
199
+ function applyAutoCompactionConfig(
200
+ raw: Record<string, unknown>,
201
+ resolved: CompactionConfig["autoCompaction"],
202
+ warnings: string[],
203
+ ): void {
204
+ warnUnknownFields(raw, AUTO_COMPACTION_FIELDS, "compaction.autoCompaction", warnings);
205
+ resolved.enabled = toBoolean(raw.enabled, "compaction.autoCompaction.enabled", warnings) ?? resolved.enabled;
206
+ resolved.continuation =
207
+ toCompactionContinuation(raw.continuation, "compaction.autoCompaction.continuation", warnings) ??
208
+ resolved.continuation;
209
+ resolved.unsupportedFallback =
210
+ toUnsupportedFallback(
211
+ raw.unsupportedFallback,
212
+ "compaction.autoCompaction.unsupportedFallback",
213
+ warnings,
214
+ ) ?? resolved.unsupportedFallback;
215
+ const reserveTokens = toNonNegativeInteger(
216
+ raw.reserveTokens,
217
+ "compaction.autoCompaction.reserveTokens",
218
+ warnings,
219
+ );
220
+ if (reserveTokens !== undefined) {
221
+ resolved.reserveTokens = reserveTokens;
222
+ }
223
+ }
224
+
164
225
  function applyCompactionConfig(
165
226
  raw: Record<string, unknown>,
166
227
  resolved: CompactionConfig,
@@ -205,6 +266,14 @@ function applyCompactionConfig(
205
266
  resolved.thinkingLevel =
206
267
  toThinkingLevel(raw.thinkingLevel, "compaction.thinkingLevel", warnings) ?? resolved.thinkingLevel;
207
268
 
269
+ if (raw.autoCompaction !== undefined) {
270
+ if (isRecord(raw.autoCompaction)) {
271
+ applyAutoCompactionConfig(raw.autoCompaction, resolved.autoCompaction, warnings);
272
+ } else {
273
+ warnings.push("Ignoring compaction.autoCompaction: expected a JSON object.");
274
+ }
275
+ }
276
+
208
277
  const apis = toSupportedApis(
209
278
  raw.responsesApis,
210
279
  "compaction.responsesApis",
package/src/debug.ts CHANGED
@@ -177,3 +177,61 @@ export function writeDebugArtifact(
177
177
  fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
178
178
  return filePath;
179
179
  }
180
+
181
+ /**
182
+ * Always-written, content-free failure record for an opaque replay failure.
183
+ *
184
+ * Unlike regular payload artifacts this is not gated by logProviderPayloads:
185
+ * a replay failure is a continuity-loss incident, not optional payload logging.
186
+ * It contains only structural parity signatures and identity metadata; prompt
187
+ * text, user content, credentials, and encrypted_content are never included
188
+ * (critical redaction is applied unconditionally).
189
+ */
190
+ export function writeReplayFailureArtifact(
191
+ details: {
192
+ reason: string;
193
+ parity?: { actual: string[]; expected: string[]; mismatches: string[] };
194
+ compactionEntryId?: string;
195
+ provider?: string;
196
+ api?: string;
197
+ model?: string;
198
+ },
199
+ settings: CompactionConfig,
200
+ context: ArtifactContext,
201
+ ): string | undefined {
202
+ try {
203
+ const sessionInfo = toSessionInfo(context);
204
+ const paths = resolveArtifactPaths(settings, context);
205
+ const targetDir = selectArtifactDirectory(paths, "provider-request");
206
+ ensureDir(targetDir);
207
+
208
+ const timestamp = new Date().toISOString();
209
+ const filePath = path.join(targetDir, `${timestamp.replace(/[.:]/g, "-")}-replay-failure.json`);
210
+ const data = redactCriticalValue({
211
+ event: "before_provider_request.rewrite-failed",
212
+ reason: details.reason,
213
+ parity: details.parity,
214
+ compactionEntryId: details.compactionEntryId,
215
+ provider: details.provider,
216
+ api: details.api,
217
+ model: details.model,
218
+ });
219
+ const envelope: DebugArtifactEnvelope = {
220
+ extension: COMPACTION_EXTENSION_ID,
221
+ kind: "provider-request",
222
+ timestamp,
223
+ cwd: sessionInfo.cwd,
224
+ sessionId: sessionInfo.sessionId,
225
+ sessionFile: sessionInfo.sessionFile,
226
+ sessionDir: sessionInfo.sessionDir,
227
+ redaction: { enabled: true },
228
+ data,
229
+ };
230
+ fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
231
+ return filePath;
232
+ } catch {
233
+ // The replay failure path itself must never be masked by an artifact write
234
+ // error; the caller still aborts the provider request.
235
+ return undefined;
236
+ }
237
+ }
@@ -0,0 +1,280 @@
1
+ import type { ResponsesCompatibleRequestPayload } from "./runtime";
2
+ import { isDeferredToolCarryover, type DeferredToolCarryoverV1 } from "./types";
3
+
4
+ type JsonObject = Record<string, unknown>;
5
+
6
+ type DeferredToolCompat = {
7
+ supportsAdditionalTools?: boolean;
8
+ supportsToolSearch?: boolean;
9
+ };
10
+
11
+ export type DeferredToolCarryoverMode = "additional-tools" | "tool-search";
12
+
13
+ export type DeferredToolCarryoverRewrite = {
14
+ payload: ResponsesCompatibleRequestPayload;
15
+ changed: boolean;
16
+ mode?: DeferredToolCarryoverMode;
17
+ movedToolNames: string[];
18
+ };
19
+
20
+ function isRecord(value: unknown): value is JsonObject {
21
+ return !!value && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ function getDeferredToolName(value: unknown): string | undefined {
25
+ if (!isRecord(value) || (value.type !== "function" && value.type !== "custom")) {
26
+ return undefined;
27
+ }
28
+
29
+ return typeof value.name === "string" && value.name.trim().length > 0 ? value.name : undefined;
30
+ }
31
+
32
+ function hasOptionalBooleanField(value: JsonObject, field: string): boolean {
33
+ return value[field] === undefined || typeof value[field] === "boolean";
34
+ }
35
+
36
+ function hasOptionalBooleanOrNullField(value: JsonObject, field: string): boolean {
37
+ return value[field] === undefined || value[field] === null || typeof value[field] === "boolean";
38
+ }
39
+
40
+ function isCustomToolFormat(value: unknown): boolean {
41
+ if (!isRecord(value)) return false;
42
+ if (value.type === "text") return true;
43
+ return (
44
+ value.type === "grammar" &&
45
+ (value.syntax === "lark" || value.syntax === "regex") &&
46
+ typeof value.definition === "string"
47
+ );
48
+ }
49
+
50
+ /** Validate only the provider-facing function/custom shapes emitted by official Pi. */
51
+ function isDeferredToolSchema(value: unknown): value is JsonObject {
52
+ const name = getDeferredToolName(value);
53
+ if (!name || !isRecord(value)) return false;
54
+
55
+ if (value.type === "function") {
56
+ return (
57
+ "parameters" in value &&
58
+ (value.parameters === null || isRecord(value.parameters)) &&
59
+ hasOptionalBooleanOrNullField(value, "strict") &&
60
+ hasOptionalBooleanField(value, "defer_loading") &&
61
+ (value.description === undefined || value.description === null || typeof value.description === "string")
62
+ );
63
+ }
64
+
65
+ return (
66
+ hasOptionalBooleanField(value, "defer_loading") &&
67
+ (value.description === undefined || typeof value.description === "string") &&
68
+ (value.format === undefined || isCustomToolFormat(value.format))
69
+ );
70
+ }
71
+
72
+ function addLoadedToolSchemas(tools: unknown, loaded: Set<string>): boolean {
73
+ if (!Array.isArray(tools)) return false;
74
+ for (const tool of tools) {
75
+ const name = getDeferredToolName(tool);
76
+ if (!name || !isDeferredToolSchema(tool)) return false;
77
+ loaded.add(name);
78
+ }
79
+ return true;
80
+ }
81
+
82
+ function isCompletedClientToolSearchCall(value: JsonObject): value is JsonObject & { call_id: string } {
83
+ if (
84
+ value.type !== "tool_search_call" ||
85
+ typeof value.call_id !== "string" ||
86
+ value.call_id.length === 0 ||
87
+ value.execution !== "client" ||
88
+ value.status !== "completed" ||
89
+ !isRecord(value.arguments)
90
+ ) {
91
+ return false;
92
+ }
93
+
94
+ return (
95
+ typeof value.arguments.query === "string" &&
96
+ typeof value.arguments.limit === "number" &&
97
+ Number.isInteger(value.arguments.limit) &&
98
+ value.arguments.limit >= 0
99
+ );
100
+ }
101
+
102
+ function collectLoadedToolNames(input: readonly unknown[], startIndex: number): Set<string> | undefined {
103
+ const loaded = new Set<string>();
104
+
105
+ for (let index = startIndex; index < input.length; index += 1) {
106
+ const item = input[index];
107
+ if (!isRecord(item)) continue;
108
+
109
+ if (item.type === "additional_tools") {
110
+ if (item.role !== "developer" || !addLoadedToolSchemas(item.tools, loaded)) return undefined;
111
+ continue;
112
+ }
113
+
114
+ if (item.type === "tool_search_call") {
115
+ if (!isCompletedClientToolSearchCall(item)) return undefined;
116
+ const output = input[index + 1];
117
+ if (
118
+ !isRecord(output) ||
119
+ output.type !== "tool_search_output" ||
120
+ output.call_id !== item.call_id ||
121
+ output.execution !== "client" ||
122
+ output.status !== "completed" ||
123
+ !addLoadedToolSchemas(output.tools, loaded)
124
+ ) {
125
+ return undefined;
126
+ }
127
+ index += 1;
128
+ continue;
129
+ }
130
+
131
+ if (item.type === "tool_search_output") return undefined;
132
+ }
133
+
134
+ return loaded;
135
+ }
136
+
137
+ function createToolSearchCallId(compactionEntryId: string, names: readonly string[]): string {
138
+ let hash = 2166136261;
139
+ for (const value of `${compactionEntryId}\u0000${names.join("\u0000")}`) {
140
+ hash ^= value.codePointAt(0) ?? 0;
141
+ hash = Math.imul(hash, 16777619);
142
+ }
143
+ return `pi_tool_load_${(hash >>> 0).toString(16).padStart(8, "0")}`;
144
+ }
145
+
146
+ function cloneTool(value: JsonObject): JsonObject | undefined {
147
+ try {
148
+ return structuredClone(value);
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ function selectMode(compat: DeferredToolCompat | undefined): DeferredToolCarryoverMode | undefined {
155
+ if (compat?.supportsAdditionalTools === true) return "additional-tools";
156
+ if (compat?.supportsToolSearch === true) return "tool-search";
157
+ return undefined;
158
+ }
159
+
160
+ function createLoadPoint(
161
+ mode: DeferredToolCarryoverMode,
162
+ compactionEntryId: string,
163
+ schemas: readonly JsonObject[],
164
+ ): JsonObject[] | undefined {
165
+ const clonedSchemas: JsonObject[] = [];
166
+ for (const schema of schemas) {
167
+ const clone = cloneTool(schema);
168
+ if (!clone) return undefined;
169
+ if (mode === "tool-search") clone.defer_loading = true;
170
+ clonedSchemas.push(clone);
171
+ }
172
+
173
+ if (mode === "additional-tools") {
174
+ return [{ type: "additional_tools", role: "developer", tools: clonedSchemas }];
175
+ }
176
+
177
+ const names = clonedSchemas.map((schema) => getDeferredToolName(schema));
178
+ if (names.some((name): name is undefined => name === undefined)) return undefined;
179
+ const callId = createToolSearchCallId(compactionEntryId, names as string[]);
180
+ return [
181
+ {
182
+ type: "tool_search_call",
183
+ call_id: callId,
184
+ execution: "client",
185
+ status: "completed",
186
+ arguments: { query: (names as string[]).join(" "), limit: names.length },
187
+ },
188
+ {
189
+ type: "tool_search_output",
190
+ call_id: callId,
191
+ execution: "client",
192
+ status: "completed",
193
+ tools: clonedSchemas,
194
+ },
195
+ ];
196
+ }
197
+
198
+ /**
199
+ * Restore cache-stack's activated function/custom schemas after an opaque
200
+ * compaction checkpoint. This is deliberately fail-open: malformed state or
201
+ * payloads leave the provider request untouched so Pi keeps its immediate-tool
202
+ * fallback behavior.
203
+ */
204
+ export function rewritePayloadWithDeferredToolCarryover(args: {
205
+ payload: ResponsesCompatibleRequestPayload;
206
+ carryover?: DeferredToolCarryoverV1;
207
+ compactionEntryId: string;
208
+ checkpointEndIndex: number;
209
+ compat?: DeferredToolCompat;
210
+ }): DeferredToolCarryoverRewrite {
211
+ const unchanged: DeferredToolCarryoverRewrite = {
212
+ payload: args.payload,
213
+ changed: false,
214
+ movedToolNames: [],
215
+ };
216
+
217
+ const mode = selectMode(args.compat);
218
+ if (!mode || !args.carryover || !isDeferredToolCarryover(args.carryover) || !Array.isArray(args.payload.input)) {
219
+ return unchanged;
220
+ }
221
+ if (!Number.isInteger(args.checkpointEndIndex) || args.checkpointEndIndex < 0 || args.checkpointEndIndex > args.payload.input.length) {
222
+ return unchanged;
223
+ }
224
+ if (!Array.isArray(args.payload.tools)) return unchanged;
225
+
226
+ const carryoverNames = new Set(
227
+ args.carryover.toolNames
228
+ .map((name) => name.trim())
229
+ .filter((name) => name.length > 0 && name !== "web_search"),
230
+ );
231
+ if (carryoverNames.size === 0) return unchanged;
232
+
233
+ const schemasByName = new Map<string, JsonObject>();
234
+ const duplicateNames = new Set<string>();
235
+ for (const tool of args.payload.tools) {
236
+ const name = getDeferredToolName(tool);
237
+ if (!name || !carryoverNames.has(name)) continue;
238
+ if (!isDeferredToolSchema(tool)) return unchanged;
239
+ const schema = tool;
240
+ if (schemasByName.has(name)) {
241
+ duplicateNames.add(name);
242
+ continue;
243
+ }
244
+ schemasByName.set(name, schema);
245
+ }
246
+ for (const name of duplicateNames) schemasByName.delete(name);
247
+ if (schemasByName.size === 0) return unchanged;
248
+
249
+ const loadedNames = collectLoadedToolNames(args.payload.input, args.checkpointEndIndex);
250
+ if (!loadedNames) return unchanged;
251
+ const matchingNames = [...schemasByName.keys()];
252
+ const movedNames = matchingNames.filter((name) => !loadedNames.has(name));
253
+ const matchingNameSet = new Set(matchingNames);
254
+ const nextTools = args.payload.tools.filter((tool) => {
255
+ const name = getDeferredToolName(tool);
256
+ return !name || !matchingNameSet.has(name);
257
+ });
258
+ const toolsChanged = nextTools.length !== args.payload.tools.length;
259
+ if (movedNames.length === 0 && !toolsChanged) return unchanged;
260
+
261
+ const nextInput = [...args.payload.input];
262
+ if (movedNames.length > 0) {
263
+ const schemas = movedNames.map((name) => schemasByName.get(name));
264
+ if (schemas.some((schema): schema is undefined => schema === undefined)) return unchanged;
265
+ const loadPoint = createLoadPoint(mode, args.compactionEntryId, schemas as JsonObject[]);
266
+ if (!loadPoint) return unchanged;
267
+ nextInput.splice(args.checkpointEndIndex, 0, ...loadPoint);
268
+ }
269
+
270
+ return {
271
+ payload: {
272
+ ...args.payload,
273
+ tools: nextTools,
274
+ input: nextInput,
275
+ },
276
+ changed: true,
277
+ mode,
278
+ movedToolNames: movedNames,
279
+ };
280
+ }