@oh-my-pi/pi-coding-agent 15.3.2 → 15.4.2

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 (193) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/types/cli/file-processor.d.ts +1 -1
  3. package/dist/types/config/settings-schema.d.ts +45 -3
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/debug/raw-sse.d.ts +2 -0
  6. package/dist/types/edit/file-read-cache.d.ts +15 -4
  7. package/dist/types/edit/index.d.ts +3 -8
  8. package/dist/types/edit/renderer.d.ts +1 -2
  9. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  10. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  11. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  12. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  13. package/dist/types/eval/py/executor.d.ts +1 -2
  14. package/dist/types/eval/py/kernel.d.ts +6 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  16. package/dist/types/eval/session-id.d.ts +3 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  18. package/dist/types/hashline/anchors.d.ts +15 -9
  19. package/dist/types/hashline/constants.d.ts +0 -2
  20. package/dist/types/hashline/diff.d.ts +1 -2
  21. package/dist/types/hashline/executor.d.ts +52 -0
  22. package/dist/types/hashline/hash.d.ts +44 -93
  23. package/dist/types/hashline/index.d.ts +2 -1
  24. package/dist/types/hashline/input.d.ts +2 -9
  25. package/dist/types/hashline/recovery.d.ts +3 -9
  26. package/dist/types/hashline/tokenizer.d.ts +91 -0
  27. package/dist/types/hashline/types.d.ts +5 -7
  28. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  29. package/dist/types/modes/types.d.ts +1 -0
  30. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +2 -0
  32. package/dist/types/session/agent-session.d.ts +11 -15
  33. package/dist/types/session/agent-storage.d.ts +11 -10
  34. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  35. package/dist/types/slash-commands/types.d.ts +0 -5
  36. package/dist/types/task/executor.d.ts +2 -0
  37. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  38. package/dist/types/tools/index.d.ts +2 -8
  39. package/dist/types/tools/match-line-format.d.ts +4 -4
  40. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  41. package/dist/types/tools/review.d.ts +13 -0
  42. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  43. package/dist/types/tools/search.d.ts +4 -3
  44. package/dist/types/utils/edit-mode.d.ts +1 -1
  45. package/dist/types/web/kagi.d.ts +4 -2
  46. package/dist/types/web/parallel.d.ts +4 -3
  47. package/dist/types/web/scrapers/types.d.ts +2 -1
  48. package/dist/types/web/search/index.d.ts +12 -4
  49. package/dist/types/web/search/provider.d.ts +2 -1
  50. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  51. package/dist/types/web/search/providers/base.d.ts +34 -2
  52. package/dist/types/web/search/providers/brave.d.ts +8 -1
  53. package/dist/types/web/search/providers/codex.d.ts +13 -9
  54. package/dist/types/web/search/providers/exa.d.ts +10 -1
  55. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  56. package/dist/types/web/search/providers/jina.d.ts +2 -1
  57. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  58. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  59. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  60. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  61. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  62. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  63. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  64. package/dist/types/web/search/providers/utils.d.ts +8 -6
  65. package/dist/types/web/search/providers/zai.d.ts +12 -3
  66. package/package.json +7 -7
  67. package/src/cli/file-processor.ts +12 -2
  68. package/src/cli.ts +0 -8
  69. package/src/commands/commit.ts +8 -8
  70. package/src/config/prompt-templates.ts +6 -6
  71. package/src/config/settings-schema.ts +47 -3
  72. package/src/config/settings.ts +5 -5
  73. package/src/debug/raw-sse.ts +68 -3
  74. package/src/edit/file-read-cache.ts +68 -25
  75. package/src/edit/index.ts +6 -37
  76. package/src/edit/renderer.ts +9 -47
  77. package/src/edit/streaming.ts +43 -56
  78. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  79. package/src/eval/js/context-manager.ts +64 -53
  80. package/src/eval/js/shared/local-module-loader.ts +265 -0
  81. package/src/eval/js/shared/prelude.txt +4 -0
  82. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  83. package/src/eval/js/shared/runtime.ts +129 -86
  84. package/src/eval/js/worker-core.ts +23 -38
  85. package/src/eval/py/executor.ts +155 -84
  86. package/src/eval/py/kernel.ts +10 -1
  87. package/src/eval/py/prelude.py +22 -24
  88. package/src/eval/py/runner.py +203 -85
  89. package/src/eval/py/tool-bridge.ts +17 -10
  90. package/src/eval/session-id.ts +8 -0
  91. package/src/exec/bash-executor.ts +27 -16
  92. package/src/extensibility/extensions/runner.ts +0 -1
  93. package/src/extensibility/extensions/types.ts +1 -3
  94. package/src/hashline/anchors.ts +56 -65
  95. package/src/hashline/apply.ts +29 -31
  96. package/src/hashline/constants.ts +0 -3
  97. package/src/hashline/diff-preview.ts +4 -5
  98. package/src/hashline/diff.ts +30 -4
  99. package/src/hashline/execute.ts +91 -26
  100. package/src/hashline/executor.ts +239 -0
  101. package/src/hashline/grammar.lark +12 -10
  102. package/src/hashline/hash.ts +69 -114
  103. package/src/hashline/index.ts +2 -1
  104. package/src/hashline/input.ts +48 -41
  105. package/src/hashline/prefixes.ts +21 -11
  106. package/src/hashline/recovery.ts +63 -71
  107. package/src/hashline/stream.ts +2 -2
  108. package/src/hashline/tokenizer.ts +467 -0
  109. package/src/hashline/types.ts +6 -8
  110. package/src/internal-urls/docs-index.generated.ts +7 -7
  111. package/src/modes/components/extensions/types.ts +0 -5
  112. package/src/modes/components/session-observer-overlay.ts +11 -2
  113. package/src/modes/components/settings-selector.ts +10 -1
  114. package/src/modes/components/tree-selector.ts +10 -2
  115. package/src/modes/controllers/command-controller.ts +1 -3
  116. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  117. package/src/modes/controllers/selector-controller.ts +5 -5
  118. package/src/modes/theme/theme.ts +4 -2
  119. package/src/modes/types.ts +4 -1
  120. package/src/modes/utils/ui-helpers.ts +4 -0
  121. package/src/prompts/agents/explore.md +1 -1
  122. package/src/prompts/tools/ast-edit.md +1 -1
  123. package/src/prompts/tools/ast-grep.md +1 -1
  124. package/src/prompts/tools/eval.md +1 -1
  125. package/src/prompts/tools/hashline.md +73 -94
  126. package/src/prompts/tools/read.md +4 -4
  127. package/src/prompts/tools/search.md +3 -3
  128. package/src/sdk.ts +33 -26
  129. package/src/session/agent-session.ts +59 -66
  130. package/src/session/agent-storage.ts +13 -14
  131. package/src/slash-commands/acp-builtins.ts +3 -3
  132. package/src/slash-commands/types.ts +0 -6
  133. package/src/task/executor.ts +26 -57
  134. package/src/task/index.ts +8 -4
  135. package/src/tool-discovery/tool-index.ts +0 -134
  136. package/src/tools/ast-edit.ts +36 -13
  137. package/src/tools/ast-grep.ts +45 -4
  138. package/src/tools/browser/tab-worker.ts +3 -2
  139. package/src/tools/eval.ts +2 -1
  140. package/src/tools/fetch.ts +23 -14
  141. package/src/tools/index.ts +2 -8
  142. package/src/tools/irc.ts +59 -5
  143. package/src/tools/match-line-format.ts +5 -7
  144. package/src/tools/output-schema-validator.ts +132 -0
  145. package/src/tools/read.ts +142 -31
  146. package/src/tools/review.ts +23 -0
  147. package/src/tools/search-tool-bm25.ts +3 -30
  148. package/src/tools/search.ts +48 -16
  149. package/src/tools/write.ts +3 -3
  150. package/src/tools/yield.ts +32 -41
  151. package/src/utils/edit-mode.ts +1 -2
  152. package/src/utils/file-mentions.ts +2 -2
  153. package/src/web/kagi.ts +15 -6
  154. package/src/web/parallel.ts +9 -6
  155. package/src/web/scrapers/types.ts +7 -1
  156. package/src/web/scrapers/youtube.ts +13 -7
  157. package/src/web/search/index.ts +37 -11
  158. package/src/web/search/provider.ts +5 -3
  159. package/src/web/search/providers/anthropic.ts +30 -21
  160. package/src/web/search/providers/base.ts +35 -2
  161. package/src/web/search/providers/brave.ts +4 -4
  162. package/src/web/search/providers/codex.ts +118 -89
  163. package/src/web/search/providers/exa.ts +3 -2
  164. package/src/web/search/providers/gemini.ts +58 -155
  165. package/src/web/search/providers/jina.ts +4 -4
  166. package/src/web/search/providers/kagi.ts +17 -11
  167. package/src/web/search/providers/kimi.ts +29 -13
  168. package/src/web/search/providers/parallel.ts +171 -23
  169. package/src/web/search/providers/perplexity.ts +38 -37
  170. package/src/web/search/providers/searxng.ts +3 -1
  171. package/src/web/search/providers/synthetic.ts +16 -19
  172. package/src/web/search/providers/tavily.ts +23 -18
  173. package/src/web/search/providers/utils.ts +11 -17
  174. package/src/web/search/providers/zai.ts +16 -8
  175. package/dist/types/hashline/parser.d.ts +0 -7
  176. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  177. package/dist/types/tools/vim.d.ts +0 -58
  178. package/dist/types/vim/buffer.d.ts +0 -41
  179. package/dist/types/vim/commands.d.ts +0 -6
  180. package/dist/types/vim/engine.d.ts +0 -47
  181. package/dist/types/vim/parser.d.ts +0 -3
  182. package/dist/types/vim/render.d.ts +0 -25
  183. package/dist/types/vim/types.d.ts +0 -182
  184. package/src/hashline/parser.ts +0 -246
  185. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  186. package/src/prompts/tools/vim.md +0 -98
  187. package/src/tools/vim.ts +0 -949
  188. package/src/vim/buffer.ts +0 -309
  189. package/src/vim/commands.ts +0 -382
  190. package/src/vim/engine.ts +0 -2409
  191. package/src/vim/parser.ts +0 -134
  192. package/src/vim/render.ts +0 -252
  193. package/src/vim/types.ts +0 -197
@@ -10,6 +10,7 @@ import type { Settings } from "../config/settings";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import { type Theme, theme } from "../modes/theme/theme";
12
12
  import type { ToolSession } from "../sdk";
13
+ import type { AgentStorage } from "../session/agent-storage";
13
14
  import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
14
15
  import { renderStatusLine } from "../tui";
15
16
  import { CachedOutputBlock } from "../tui/output-block";
@@ -542,7 +543,8 @@ async function renderHtmlToText(
542
543
  html: string,
543
544
  timeout: number,
544
545
  settings: Settings,
545
- userSignal?: AbortSignal,
546
+ userSignal: AbortSignal | undefined,
547
+ storage: AgentStorage | null,
546
548
  ): Promise<{ content: string; ok: boolean; method: string }> {
547
549
  const signal = ptree.combineSignals(userSignal, timeout * 1000);
548
550
  const execOptions = {
@@ -554,14 +556,18 @@ async function renderHtmlToText(
554
556
  };
555
557
 
556
558
  // Try Parallel extract first when credentials are configured
557
- if (settings.get("providers.parallelFetch") && (await findParallelApiKey())) {
559
+ if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
558
560
  try {
559
- const parallelResult = await extractWithParallel([url], {
560
- objective: "Extract the main content",
561
- excerpts: true,
562
- fullContent: false,
563
- signal,
564
- });
561
+ const parallelResult = await extractWithParallel(
562
+ [url],
563
+ {
564
+ objective: "Extract the main content",
565
+ excerpts: true,
566
+ fullContent: false,
567
+ signal,
568
+ },
569
+ storage,
570
+ );
565
571
  const firstDocument = parallelResult.results[0];
566
572
  if (firstDocument) {
567
573
  const content = getParallelExtractContent(firstDocument);
@@ -682,13 +688,14 @@ type FetchRenderResult = RenderResult & {
682
688
  async function handleSpecialUrls(
683
689
  url: string,
684
690
  timeout: number,
685
- signal?: AbortSignal,
691
+ signal: AbortSignal | undefined,
692
+ storage: AgentStorage | null,
686
693
  ): Promise<FetchRenderResult | null> {
687
694
  for (const handler of specialHandlers) {
688
695
  if (signal?.aborted) {
689
696
  throw new ToolAbortError();
690
697
  }
691
- const result = await handler(url, timeout, signal);
698
+ const result = await handler(url, timeout, signal, storage);
692
699
  if (result) return result;
693
700
  }
694
701
  return null;
@@ -706,7 +713,8 @@ async function renderUrl(
706
713
  timeout: number,
707
714
  raw: boolean,
708
715
  settings: Settings,
709
- signal?: AbortSignal,
716
+ signal: AbortSignal | undefined,
717
+ storage: AgentStorage | null,
710
718
  ): Promise<FetchRenderResult> {
711
719
  const notes: string[] = [];
712
720
  const fetchedAt = new Date().toISOString();
@@ -733,7 +741,7 @@ async function renderUrl(
733
741
 
734
742
  // Step 1: Try special handlers for known sites (unless raw mode)
735
743
  if (!raw) {
736
- const specialResult = await handleSpecialUrls(url, timeout, signal);
744
+ const specialResult = await handleSpecialUrls(url, timeout, signal, storage);
737
745
  if (specialResult) return specialResult;
738
746
  }
739
747
 
@@ -1051,7 +1059,7 @@ async function renderUrl(
1051
1059
  }
1052
1060
 
1053
1061
  // 5E: Render HTML with lynx or html2text
1054
- const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal);
1062
+ const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage);
1055
1063
  if (!htmlResult.ok) {
1056
1064
  notes.push("html rendering failed (lynx/html2text unavailable)");
1057
1065
  const output = finalizeOutput(rawContent);
@@ -1233,7 +1241,8 @@ async function buildReadUrlCacheEntry(
1233
1241
  throw new ToolAbortError();
1234
1242
  }
1235
1243
 
1236
- const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal);
1244
+ const storage = session.settings.getStorage();
1245
+ const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal, storage);
1237
1246
  const output = buildUrlReadOutput(result, result.content);
1238
1247
  const artifactId = options?.ensureArtifact ? await persistReadUrlArtifact(session, output) : undefined;
1239
1248
 
@@ -91,7 +91,6 @@ export * from "./search";
91
91
  export * from "./search-tool-bm25";
92
92
  export * from "./ssh";
93
93
  export * from "./todo-write";
94
- export * from "./vim";
95
94
  export * from "./write";
96
95
  export * from "./yield";
97
96
 
@@ -104,7 +103,6 @@ export type ContextFileEntry = {
104
103
  depth?: number;
105
104
  };
106
105
 
107
- export type { DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata";
108
106
  export type {
109
107
  DiscoverableTool,
110
108
  DiscoverableToolSearchIndex,
@@ -140,6 +138,8 @@ export interface ToolSession {
140
138
  requireYieldTool?: boolean;
141
139
  /** Task recursion depth (0 = top-level, 1 = first child, etc.) */
142
140
  taskDepth?: number;
141
+ /** Get shared eval executor session ID. Subagents inherit this to share JS/Python state. */
142
+ getEvalSessionId?: () => string | null;
143
143
  /** Get session file */
144
144
  getSessionFile: () => string | null;
145
145
  /** Get eval kernel owner ID for session-scoped retained-kernel cleanup. */
@@ -194,12 +194,6 @@ export interface ToolSession {
194
194
  setTodoPhases?: (phases: TodoPhase[]) => void;
195
195
  /** Whether MCP tool discovery is active for this session. */
196
196
  isMCPDiscoveryEnabled?: () => boolean;
197
- /** Get hidden-but-discoverable MCP tools for search_tool_bm25 prompts and fallbacks.
198
- * @deprecated Use getDiscoverableTools with source filter instead. */
199
- getDiscoverableMCPTools?: () => import("../mcp/discoverable-tool-metadata").DiscoverableMCPTool[];
200
- /** Get the cached discoverable MCP search index for search_tool_bm25 execution.
201
- * @deprecated Use getDiscoverableToolSearchIndex instead. */
202
- getDiscoverableMCPSearchIndex?: () => import("../tool-discovery/tool-index").DiscoverableMCPSearchIndex;
203
197
  /** Get MCP tools activated by prior search_tool_bm25 calls. */
204
198
  getSelectedMCPToolNames?: () => string[];
205
199
  /** Merge MCP tool selections into the active session tool set. */
package/src/tools/irc.ts CHANGED
@@ -25,6 +25,7 @@ import ircDescription from "../prompts/tools/irc.md" with { type: "text" };
25
25
  import type { AgentRef, AgentRegistry } from "../registry/agent-registry";
26
26
  import type { ToolSession } from ".";
27
27
 
28
+ const DEFAULT_IRC_TIMEOUT_MS = 120_000;
28
29
  const ircSchema = z.object({
29
30
  op: z.enum(["send", "list"]).describe("irc operation"),
30
31
  to: z.string().optional().describe('recipient agent id or "all"'),
@@ -159,6 +160,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
159
160
 
160
161
  const awaitReply = params.awaitReply ?? !isBroadcast;
161
162
 
163
+ const timeoutMs = normalizeIrcTimeoutMs(this.session.settings.get("irc.timeoutMs"));
162
164
  const delivered: string[] = [];
163
165
  const replies: IrcReply[] = [];
164
166
  const failed: Array<{ id: string; error: string }> = [];
@@ -174,12 +176,18 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
174
176
  return;
175
177
  }
176
178
  try {
177
- const result = await targetSession.respondAsBackground({
178
- from: senderId,
179
- message,
180
- awaitReply,
179
+ const result = await runIrcDispatchWithTimeout(
180
+ timeoutMs,
181
181
  signal,
182
- });
182
+ timeoutSignal =>
183
+ targetSession.respondAsBackground({
184
+ from: senderId,
185
+ message,
186
+ awaitReply,
187
+ signal: timeoutSignal,
188
+ }),
189
+ target.id,
190
+ );
183
191
  delivered.push(target.id);
184
192
  if (awaitReply && result.replyText) {
185
193
  replies.push({ from: target.id, text: result.replyText });
@@ -237,3 +245,49 @@ function errorResult(text: string, details: IrcDetails): AgentToolResult<IrcDeta
237
245
  details,
238
246
  };
239
247
  }
248
+
249
+ function normalizeIrcTimeoutMs(value: number): number {
250
+ if (!Number.isFinite(value) || value === 0) return value === 0 ? 0 : DEFAULT_IRC_TIMEOUT_MS;
251
+ return Math.max(1, Math.trunc(value));
252
+ }
253
+
254
+ async function runIrcDispatchWithTimeout<T>(
255
+ timeoutMs: number,
256
+ parentSignal: AbortSignal | undefined,
257
+ run: (signal?: AbortSignal) => Promise<T>,
258
+ targetId: string,
259
+ ): Promise<T> {
260
+ if (timeoutMs <= 0) {
261
+ return await run(parentSignal);
262
+ }
263
+
264
+ const controller = new AbortController();
265
+ const timeoutError = new Error(`IRC timed out waiting for ${targetId} after ${timeoutMs} ms`);
266
+ let timeout: NodeJS.Timeout | undefined;
267
+ let parentAbortListener: (() => void) | undefined;
268
+
269
+ const timeoutDeferred = Promise.withResolvers<never>();
270
+ if (parentSignal) {
271
+ if (parentSignal.aborted) {
272
+ throw parentSignal.reason instanceof Error ? parentSignal.reason : new Error("IRC aborted");
273
+ }
274
+ parentAbortListener = () => {
275
+ controller.abort(parentSignal.reason);
276
+ timeoutDeferred.reject(parentSignal.reason instanceof Error ? parentSignal.reason : new Error("IRC aborted"));
277
+ };
278
+ parentSignal.addEventListener("abort", parentAbortListener, { once: true });
279
+ }
280
+
281
+ timeout = setTimeout(() => {
282
+ controller.abort(timeoutError);
283
+ timeoutDeferred.reject(timeoutError);
284
+ }, timeoutMs);
285
+ timeout.unref?.();
286
+
287
+ try {
288
+ return await Promise.race([run(controller.signal), timeoutDeferred.promise]);
289
+ } finally {
290
+ if (timeout) clearTimeout(timeout);
291
+ if (parentSignal && parentAbortListener) parentSignal.removeEventListener("abort", parentAbortListener);
292
+ }
293
+ }
@@ -1,12 +1,10 @@
1
- import { computeLineHash } from "../hashline/hash";
2
-
3
1
  /**
4
2
  * Format a single line of match output for grep/ast-grep style results.
5
3
  *
6
- * The anchor/content separator is always `|`. Matched lines are prefixed
7
- * with `*`; context lines are prefixed with a single space so anchors
8
- * align in column. In hashline mode the anchor is `LINE+ID` (no `#`); in
9
- * plain mode it is just the line number. Line numbers are never padded.
4
+ * Matched lines are prefixed with `*`; context lines are prefixed with a single
5
+ * space so line numbers align in column. In hashline mode the line uses the
6
+ * editable `LINE:content` shape under a file-hash header; in plain mode it keeps
7
+ * the legacy `LINE|content` display-only shape. Line numbers are never padded.
10
8
  */
11
9
  export function formatMatchLine(
12
10
  lineNumber: number,
@@ -16,7 +14,7 @@ export function formatMatchLine(
16
14
  ): string {
17
15
  const marker = isMatch ? "*" : " ";
18
16
  if (options.useHashLines) {
19
- return `${marker}${lineNumber}${computeLineHash(lineNumber, line)}|${line}`;
17
+ return `${marker}${lineNumber}:${line}`;
20
18
  }
21
19
  return `${marker}${lineNumber}|${line}`;
22
20
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared output-schema validation for subagent yield + executor finalization.
3
+ *
4
+ * Both the in-process `yield` tool (subagent side) and the executor's post-mortem
5
+ * finalize path (parent side) need to validate yield payloads against the agent's
6
+ * declared output schema. This module is the single source of truth for that
7
+ * pipeline — keeping the two callsites in lockstep so a schema accepted in-tool
8
+ * cannot be rejected post-mortem (or vice versa).
9
+ */
10
+ import {
11
+ isValidJsonSchema,
12
+ type JsonSchemaValidationIssue,
13
+ type JsonSchemaValidationResult,
14
+ validateJsonSchemaValue,
15
+ } from "@oh-my-pi/pi-ai/utils/schema";
16
+ import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
17
+
18
+ /** A validator bound to a specific output schema. */
19
+ export interface OutputValidator {
20
+ /** Run JSON Schema validation; returns the raw `success`/`issues` shape so callers may inspect every failure. */
21
+ validate(value: unknown): JsonSchemaValidationResult;
22
+ /** Top-level required property names. Empty if the schema has no `required` array at root. */
23
+ readonly requiredFields: readonly string[];
24
+ }
25
+
26
+ export interface BuildOutputValidatorResult {
27
+ /** Present when the schema produced a usable validator (i.e. constraining schemas). Absent for missing/unconstrained schemas. */
28
+ validator?: OutputValidator;
29
+ /** Raw JSON Schema produced by `jtdToJsonSchema`. Available alongside the validator so callers can derive related artifacts (strict-mode probe, dereference, hint text). */
30
+ jsonSchema?: Record<string, unknown>;
31
+ /**
32
+ * Normalized schema (post-`normalizeSchema`). Surfaced so callers can distinguish
33
+ * "no schema provided" (`undefined`) from "intentionally unconstrained" (`true`)
34
+ * when both produce no validator.
35
+ */
36
+ normalized?: unknown;
37
+ /** Set when the schema cannot be used. Callers should treat this as a "no validation" case (loose acceptance) and surface the message in diagnostics. */
38
+ error?: string;
39
+ }
40
+
41
+ /**
42
+ * Build the canonical validator for a JTD-or-JSON-Schema output declaration.
43
+ *
44
+ * Returns:
45
+ * - `{ validator, jsonSchema, normalized }` for constraining schemas — both callers use this path.
46
+ * - `{ normalized: true }` for an intentionally unconstrained schema (the JSON Schema literal `true`).
47
+ * No validator, but distinguishable from "no schema provided".
48
+ * - `{}` for an absent schema (`undefined`).
49
+ * - `{ error, normalized? }` when the schema cannot be honored (invalid syntax, `false`, malformed JTD).
50
+ */
51
+ export function buildOutputValidator(schema: unknown): BuildOutputValidatorResult {
52
+ const { normalized, error: normalizeError } = normalizeSchema(schema);
53
+ if (normalizeError) return { error: normalizeError, normalized };
54
+ if (normalized === undefined) return {};
55
+ if (normalized === false) return { error: "boolean false schema rejects all outputs", normalized };
56
+ if (normalized === true) return { normalized };
57
+
58
+ const jsonSchema = jtdToJsonSchema(normalized);
59
+ if (jsonSchema === undefined) return { normalized };
60
+ if (jsonSchema === false) return { error: "boolean false schema rejects all outputs", normalized };
61
+ if (jsonSchema === true) return { normalized };
62
+ if (typeof jsonSchema !== "object" || Array.isArray(jsonSchema)) {
63
+ return { error: "invalid JSON schema", normalized };
64
+ }
65
+ if (!isValidJsonSchema(jsonSchema)) return { error: "invalid JSON schema", normalized };
66
+
67
+ const jsonSchemaRecord = jsonSchema as Record<string, unknown>;
68
+ const required = extractRequiredFields(jsonSchemaRecord);
69
+ return {
70
+ normalized,
71
+ jsonSchema: jsonSchemaRecord,
72
+ validator: {
73
+ requiredFields: required,
74
+ validate: value => validateJsonSchemaValue(jsonSchemaRecord, value),
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Produce the executor's headline+missing-required summary from a failed validation. */
80
+ export function summarizeValidationFailure(
81
+ result: JsonSchemaValidationResult,
82
+ value: unknown,
83
+ requiredFields: readonly string[],
84
+ ): { message: string; missingRequired: string[] } {
85
+ if (result.success) return { message: "", missingRequired: [] };
86
+ const missing = computeMissingRequired(requiredFields, value);
87
+ const message = formatValidationIssueHeadline(result.issues[0]) ?? "schema validation failed";
88
+ return { message, missingRequired: missing };
89
+ }
90
+
91
+ export function extractRequiredFields(jsonSchema: unknown): string[] {
92
+ if (!jsonSchema || typeof jsonSchema !== "object") return [];
93
+ const required = (jsonSchema as { required?: unknown }).required;
94
+ return Array.isArray(required) ? required.filter((k): k is string => typeof k === "string") : [];
95
+ }
96
+
97
+ export function computeMissingRequired(required: readonly string[], value: unknown): string[] {
98
+ if (required.length === 0) return [];
99
+ if (value === null || value === undefined) return [...required];
100
+ if (typeof value !== "object" || Array.isArray(value)) return [];
101
+ const record = value as Record<string, unknown>;
102
+ return required.filter(key => !(key in record) || record[key] === undefined);
103
+ }
104
+
105
+ /**
106
+ * Format a single validation issue as `path.with.dots: message`.
107
+ *
108
+ * Used by the executor's post-mortem `schema_violation` headline — one line, dot-separated path,
109
+ * since the executor's error format already lists missing-required fields separately.
110
+ */
111
+ export function formatValidationIssueHeadline(issue: JsonSchemaValidationIssue | undefined): string | undefined {
112
+ if (!issue) return undefined;
113
+ const path = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
114
+ return `${path}: ${issue.message}`;
115
+ }
116
+
117
+ /**
118
+ * Format every validation issue as `path/with/slashes: message; ...`.
119
+ *
120
+ * Used by the yield tool's model-facing retry feedback — the model gets every problem at once so it
121
+ * can fix the entire output in one retry instead of iterating issue-by-issue. The slash separator
122
+ * mirrors JSON Pointer convention and disambiguates against fields whose names contain dots.
123
+ */
124
+ export function formatAllValidationIssues(issues: ReadonlyArray<JsonSchemaValidationIssue> | undefined): string {
125
+ if (!issues || issues.length === 0) return "Unknown schema validation error.";
126
+ return issues
127
+ .map(issue => {
128
+ const path = issue.path.length === 0 ? "" : `${issue.path.map(seg => String(seg)).join("/")}: `;
129
+ return `${path}${issue.message}`;
130
+ })
131
+ .join("; ");
132
+ }