@oh-my-pi/pi-coding-agent 16.2.7 → 16.2.8

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.
@@ -144,7 +144,7 @@ export class ToolArgsRevealController {
144
144
  entry = {
145
145
  component: undefined,
146
146
  target: partialJson,
147
- revealed: 0,
147
+ revealed: clampSliceEnd(partialJson, partialJson.length),
148
148
  rawInput,
149
149
  exposeRawPartialJson,
150
150
  parsedArgs: {},
@@ -29,13 +29,10 @@ Anything below → `eval` cell, not bash:
29
29
 
30
30
  <critical>
31
31
  - Bash invokes real binaries with simple args; it is NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
32
- - NEVER shell out to search content or files: `grep/rg` → `grep`.
33
- - NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing.
34
- - Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
35
32
  </critical>
36
33
 
37
34
  <output>
38
- - Returns output; exit code shown on non-zero exit.
35
+ - Returns output (stderr merged into stdout); exit code shown on non-zero exit.
39
36
  - Truncated output → `artifact://<id>` (linked in metadata).
40
37
  </output>
41
38
 
@@ -8968,6 +8968,22 @@ export class AgentSession {
8968
8968
  ...(snapcompactShapeSetting === "auto" ? {} : { shape }),
8969
8969
  maxFrames,
8970
8970
  });
8971
+ const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
8972
+ if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
8973
+ logger.warn("Snapcompact exceeded the per-request frame payload budget", {
8974
+ model: this.model?.id,
8975
+ framePayloadBytes,
8976
+ budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
8977
+ });
8978
+ this.emitNotice(
8979
+ "warning",
8980
+ "snapcompact produced too much standing image payload. No LLM fallback was attempted.",
8981
+ "compaction",
8982
+ );
8983
+ throw new Error(
8984
+ "snapcompact cannot run locally: standing image payload exceeds the per-request budget.",
8985
+ );
8986
+ }
8971
8987
  const ctxWindow = this.model?.contextWindow ?? 0;
8972
8988
  const budget =
8973
8989
  ctxWindow > 0
@@ -10930,7 +10946,7 @@ export class AgentSession {
10930
10946
  */
10931
10947
  #computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
10932
10948
  const ctxWindow = this.model?.contextWindow ?? 0;
10933
- if (ctxWindow <= 0) return snapcompact.MAX_FRAMES_DEFAULT;
10949
+ if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
10934
10950
  const reserve = effectiveReserveTokens(ctxWindow, settings);
10935
10951
  let baseTokens = computeNonMessageTokens(this);
10936
10952
  for (const message of preparation.recentMessages) {
@@ -10969,7 +10985,16 @@ export class AgentSession {
10969
10985
  const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
10970
10986
  const frameBudget = totalBudget - baseTokens - capReserve;
10971
10987
  if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
10972
- return Math.min(Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT);
10988
+ return Math.min(
10989
+ Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
10990
+ snapcompact.MAX_FRAMES_DEFAULT,
10991
+ snapcompact.maxFramesForDataBudget(),
10992
+ );
10993
+ }
10994
+
10995
+ #snapcompactFramePayloadBytes(result: snapcompact.CompactionResult): number {
10996
+ const archive = snapcompact.getPreservedArchive(result.preserveData);
10997
+ return archive ? snapcompact.frameDataBytes(archive.frames) : 0;
10973
10998
  }
10974
10999
 
10975
11000
  /**
@@ -10982,7 +11007,9 @@ export class AgentSession {
10982
11007
  */
10983
11008
  #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
10984
11009
  const archive = snapcompact.getPreservedArchive(result.preserveData);
10985
- const blocks = archive ? snapcompact.historyBlocks(archive) : undefined;
11010
+ const blocks = archive
11011
+ ? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET })
11012
+ : undefined;
10986
11013
  const summaryMessage = createCompactionSummaryMessage(
10987
11014
  result.summary,
10988
11015
  result.tokensBefore,
@@ -11073,6 +11100,52 @@ export class AgentSession {
11073
11100
  return residualTokens <= fitBudget;
11074
11101
  }
11075
11102
 
11103
+ /**
11104
+ * Last-resort reducer when {@link #runAutoCompaction} would otherwise dead-end.
11105
+ * The summarizer cut at the only available turn boundary, but the kept tail is
11106
+ * still over the recovery band because a single recent turn (a large
11107
+ * tool-result, a heavy fenced/XML block) is itself bigger than the band and
11108
+ * `findCutPoint` cannot cut inside one message. `shake("elide")` reaches INSIDE
11109
+ * that tail — it offloads heavy tool-result / block content to one
11110
+ * `artifact://` blob and leaves a recoverable placeholder — so residual context
11111
+ * genuinely drops instead of the guard pausing maintenance and looping the
11112
+ * warning. Without it the guard would pause/warn here; with it the caller
11113
+ * re-tests its progress predicate after the elide pass and only falls through
11114
+ * to the warning when residual stays over.
11115
+ *
11116
+ * Image-only tails are out of scope: `collectShakeRegions` skips image-only
11117
+ * tool results and user-message images aren't counted by the local estimate
11118
+ * that gates the dead-end, so those still surface the warning (remedy:
11119
+ * `/shake images`).
11120
+ *
11121
+ * Returns the elide {@link ShakeResult} when something was offloaded (so the
11122
+ * caller can re-test and report), or `undefined` when nothing was eligible or
11123
+ * the pass aborted/failed.
11124
+ */
11125
+ async #tryShakeRescueForDeadEnd(signal: AbortSignal): Promise<ShakeResult | undefined> {
11126
+ if (signal.aborted) return undefined;
11127
+ try {
11128
+ const result = await this.shake("elide", { signal });
11129
+ return result.toolResultsDropped + result.blocksDropped > 0 ? result : undefined;
11130
+ } catch (error) {
11131
+ logger.warn("Dead-end shake rescue failed", {
11132
+ error: error instanceof Error ? error.message : String(error),
11133
+ });
11134
+ return undefined;
11135
+ }
11136
+ }
11137
+
11138
+ /** Notice describing a successful dead-end elide rescue. */
11139
+ #emitShakeRescueNotice(result: ShakeResult): void {
11140
+ const elided = result.toolResultsDropped + result.blocksDropped;
11141
+ const sink = result.artifactId ? "an artifact" : "placeholders";
11142
+ this.emitNotice(
11143
+ "info",
11144
+ `Compaction dead-end recovery: elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${result.tokensFreed.toLocaleString()} tokens) to ${sink} so maintenance could make progress.`,
11145
+ "compaction",
11146
+ );
11147
+ }
11148
+
11076
11149
  /**
11077
11150
  * Internal: Run auto-compaction with events.
11078
11151
  *
@@ -11106,6 +11179,7 @@ export class AgentSession {
11106
11179
  const shouldAutoContinue =
11107
11180
  !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
11108
11181
  const suppressHandoff = options.suppressHandoff === true;
11182
+ let fallbackFromShake = false;
11109
11183
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
11110
11184
  // reclaims nothing we fall through to the summary-compaction body below so
11111
11185
  // the oversized input still gets resolved.
@@ -11119,6 +11193,7 @@ export class AgentSession {
11119
11193
  suppressContinuation,
11120
11194
  );
11121
11195
  if (outcome !== "fallback") return outcome;
11196
+ fallbackFromShake = true;
11122
11197
  }
11123
11198
  // "overflow" and "incomplete" force inline execution because they are recovery
11124
11199
  // paths the caller wants resolved before scheduling the next turn. "idle" is
@@ -11347,6 +11422,17 @@ export class AgentSession {
11347
11422
  ...(shapeSetting === "auto" ? {} : { shape }),
11348
11423
  maxFrames,
11349
11424
  });
11425
+ const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
11426
+ if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
11427
+ logger.warn("Snapcompact exceeded the per-request frame payload budget", {
11428
+ model: this.model?.id,
11429
+ framePayloadBytes,
11430
+ budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
11431
+ });
11432
+ snapcompactBlocker =
11433
+ "snapcompact produced too much standing image payload; using context-full auto-compaction instead.";
11434
+ snapcompactResult = undefined;
11435
+ }
11350
11436
  if (snapcompactResult) {
11351
11437
  const ctxWindow = this.model?.contextWindow ?? 0;
11352
11438
  const budget =
@@ -11606,7 +11692,15 @@ export class AgentSession {
11606
11692
  // won't include) is excluded. Reusing the auto-continue recovery band
11607
11693
  // here turned recoverable overflows into manual dead-ends (#3412 review),
11608
11694
  // so use the looser fit budget.
11609
- if (this.#compactionCreatedRetryFit()) {
11695
+ let retryFits = this.#compactionCreatedRetryFit();
11696
+ if (!retryFits && !fallbackFromShake) {
11697
+ const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
11698
+ if (rescue && this.#compactionCreatedRetryFit()) {
11699
+ retryFits = true;
11700
+ this.#emitShakeRescueNotice(rescue);
11701
+ }
11702
+ }
11703
+ if (retryFits) {
11610
11704
  this.#scheduleAgentContinue({ delayMs: 100, generation });
11611
11705
  continuationScheduled = true;
11612
11706
  } else {
@@ -11620,7 +11714,15 @@ export class AgentSession {
11620
11714
  // when auto-continue is disabled, a no-headroom threshold pass must still
11621
11715
  // block later automatic continuations (todo reminders/session_stop hooks)
11622
11716
  // from re-entering the same oversized context.
11623
- if (this.#compactionCreatedHeadroom()) {
11717
+ let hasHeadroom = this.#compactionCreatedHeadroom();
11718
+ if (!hasHeadroom && !fallbackFromShake) {
11719
+ const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
11720
+ if (rescue && this.#compactionCreatedHeadroom()) {
11721
+ hasHeadroom = true;
11722
+ this.#emitShakeRescueNotice(rescue);
11723
+ }
11724
+ }
11725
+ if (hasHeadroom) {
11624
11726
  if (shouldAutoContinue) {
11625
11727
  this.#scheduleAutoContinuePrompt(generation);
11626
11728
  continuationScheduled = true;
@@ -11644,7 +11746,7 @@ export class AgentSession {
11644
11746
  if (noProgressDeadEnd) {
11645
11747
  this.emitNotice(
11646
11748
  "warning",
11647
- "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; shrink it (e.g. clear large tool output) or switch to a larger-context model.",
11749
+ "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; clear large tool output, run `/shake images` to drop attached images, or switch to a larger-context model.",
11648
11750
  "compaction",
11649
11751
  );
11650
11752
  }
@@ -77,6 +77,17 @@ export interface BuildSessionContextOptions {
77
77
  * If leafId is provided, walks from that entry to root.
78
78
  * Handles compaction and branch summaries along the path.
79
79
  */
80
+ function snapcompactHistoryBlocksForContext(
81
+ archive: snapcompact.Archive | undefined,
82
+ options: BuildSessionContextOptions | undefined,
83
+ ) {
84
+ if (!archive) return undefined;
85
+ return snapcompact.historyBlocks(
86
+ archive,
87
+ options?.transcript ? undefined : { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET },
88
+ );
89
+ }
90
+
80
91
  export function buildSessionContext(
81
92
  entries: SessionEntry[],
82
93
  leafId?: string | null,
@@ -273,7 +284,7 @@ export function buildSessionContext(
273
284
  entry.shortSummary,
274
285
  undefined,
275
286
  undefined,
276
- snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
287
+ snapcompactHistoryBlocksForContext(snapcompactArchive, options),
277
288
  ),
278
289
  );
279
290
  } else {
@@ -307,7 +318,7 @@ export function buildSessionContext(
307
318
  compaction.shortSummary,
308
319
  providerPayload,
309
320
  undefined,
310
- snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
321
+ snapcompactHistoryBlocksForContext(snapcompactArchive, options),
311
322
  ),
312
323
  );
313
324
 
@@ -2,8 +2,8 @@
2
2
  * Settings-aware stream wrapper shared by the main agent (sdk.ts) and the
3
3
  * advisor agent (AgentSession.#buildAdvisorRuntime).
4
4
  *
5
- * Reads OpenRouter / Antigravity routing variants, Responses-family text
6
- * verbosity, per-provider in-flight caps, and the loop guard out of `Settings`
5
+ * verbosity, stream watchdog budgets, per-provider in-flight caps, and the loop
6
+ * guard out of `Settings`
7
7
  * per request, layering them onto whatever options the caller passed. Before
8
8
  * this helper existed, advisor turns called bare `streamSimple` while the main
9
9
  * turn went through an inline closure that read these settings — so an advisor on
@@ -14,6 +14,12 @@ import type { StreamFn } from "@oh-my-pi/pi-agent-core";
14
14
  import { type SimpleStreamOptions, streamSimple } from "@oh-my-pi/pi-ai";
15
15
  import { type Settings, validateProviderMaxInFlightRequests } from "../config/settings";
16
16
 
17
+ function timeoutSecondsToMs(value: number): number | undefined {
18
+ if (!Number.isFinite(value) || value < 0) return undefined;
19
+ if (value === 0) return 0;
20
+ return Math.max(1, Math.trunc(value * 1000));
21
+ }
22
+
17
23
  /**
18
24
  * Build a {@link StreamFn} that reads provider routing/guard settings from
19
25
  * `settings` per call and forwards to `base` (defaults to `streamSimple`).
@@ -30,11 +36,15 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
30
36
  model.api === "openai-codex-responses" || model.api === "openai-responses"
31
37
  ? settings.get("textVerbosity")
32
38
  : undefined;
39
+ const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds"));
40
+ const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds"));
33
41
  const merged: SimpleStreamOptions = {
34
42
  ...streamOptions,
35
43
  openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
36
44
  antigravityEndpointMode: streamOptions?.antigravityEndpointMode ?? antigravityEndpointMode,
37
45
  textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
46
+ streamFirstEventTimeoutMs: streamOptions?.streamFirstEventTimeoutMs ?? streamFirstEventTimeoutMs,
47
+ streamIdleTimeoutMs: streamOptions?.streamIdleTimeoutMs ?? streamIdleTimeoutMs,
38
48
  maxInFlightRequests: validateProviderMaxInFlightRequests(
39
49
  streamOptions?.maxInFlightRequests ?? settings.get("providers.maxInFlightRequests"),
40
50
  ),
package/src/tools/grep.ts CHANGED
@@ -85,8 +85,26 @@ const searchSchema = type({
85
85
  });
86
86
 
87
87
  export type GrepToolInput = typeof searchSchema.infer;
88
+ function parseStringEncodedPathArray(input: string): string[] | null {
89
+ const trimmed = input.trim();
90
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
91
+
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(trimmed);
95
+ } catch {
96
+ return null;
97
+ }
98
+
99
+ if (!Array.isArray(parsed) || parsed.some(entry => typeof entry !== "string")) {
100
+ return null;
101
+ }
102
+ return parsed;
103
+ }
104
+
88
105
  export function toPathList(input: string | string[] | undefined): string[] {
89
- return typeof input === "string" ? [input] : (input ?? []);
106
+ if (typeof input === "string") return parseStringEncodedPathArray(input) ?? [input];
107
+ return input ?? [];
90
108
  }
91
109
 
92
110
  /** Maximum number of distinct files surfaced in a single response. The
@@ -21,6 +21,16 @@ export interface OutputValidator {
21
21
  validate(value: unknown): JsonSchemaValidationResult;
22
22
  /** Top-level required property names. Empty if the schema has no `required` array at root. */
23
23
  readonly requiredFields: readonly string[];
24
+ /**
25
+ * Per-label validators for incremental yields (`type: ["<label>"]`). Each entry validates the
26
+ * `data` payload of a single section against the matching top-level property's sub-schema —
27
+ * array-typed properties (e.g. `findings`) use the items schema since each yield contributes
28
+ * one element, while scalar properties use the property schema directly. Unknown labels (not
29
+ * top-level properties) have no entry and skip per-call validation. Lets the yield tool give
30
+ * the model retry feedback on a section as soon as it arrives, instead of deferring every
31
+ * mismatch to the parent's post-mortem `schema_violation`.
32
+ */
33
+ readonly validateSection: ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult>;
24
34
  }
25
35
 
26
36
  export interface BuildOutputValidatorResult {
@@ -72,10 +82,38 @@ export function buildOutputValidator(schema: unknown): BuildOutputValidatorResul
72
82
  validator: {
73
83
  requiredFields: required,
74
84
  validate: value => validateJsonSchemaValue(jsonSchemaRecord, value),
85
+ validateSection: buildSectionValidators(jsonSchemaRecord),
75
86
  },
76
87
  };
77
88
  }
78
89
 
90
+ /**
91
+ * Build per-top-level-property validators for incremental yields.
92
+ *
93
+ * Each entry validates the `data` payload of one `type: ["<label>"]` section against the
94
+ * matching property's sub-schema — array-typed properties (e.g. `findings`, derived from JTD
95
+ * `elements`) use the items schema since each yield contributes one element, while scalar
96
+ * properties use the property schema directly. Unknown labels (anything not declared as a
97
+ * top-level property) are deliberately omitted so user-defined section labels still pass.
98
+ */
99
+ function buildSectionValidators(
100
+ jsonSchema: Record<string, unknown>,
101
+ ): ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult> {
102
+ const validators = new Map<string, (value: unknown) => JsonSchemaValidationResult>();
103
+ const properties = jsonSchema.properties;
104
+ if (properties === null || typeof properties !== "object") return validators;
105
+ for (const [label, raw] of Object.entries(properties as Record<string, unknown>)) {
106
+ if (raw === null || typeof raw !== "object") continue;
107
+ const propRecord = raw as Record<string, unknown>;
108
+ const sectionSchema =
109
+ propRecord.type === "array" && propRecord.items !== undefined && propRecord.items !== null
110
+ ? (propRecord.items as Record<string, unknown>)
111
+ : propRecord;
112
+ validators.set(label, value => validateJsonSchemaValue(sectionSchema, value));
113
+ }
114
+ return validators;
115
+ }
116
+
79
117
  /** Produce the executor's headline+missing-required summary from a failed validation. */
80
118
  export function summarizeValidationFailure(
81
119
  result: JsonSchemaValidationResult,
@@ -98,6 +98,16 @@ function parseYieldType(value: unknown): string | string[] | undefined {
98
98
  throw new Error("type must be a string or non-empty array of strings");
99
99
  }
100
100
 
101
+ /**
102
+ * Render an incremental yield's `type: [...]` labels as a quoted, comma-separated list for
103
+ * model-facing retry messages — keeps the failed section labelled even when the yield carried
104
+ * multiple labels at once.
105
+ */
106
+ function formatYieldLabels(labels: readonly string[]): string {
107
+ if (labels.length === 0) return '""';
108
+ return labels.map(label => `"${label}"`).join(", ");
109
+ }
110
+
101
111
  /**
102
112
  * Expand a plain-object `data` schema into a strict union that ALSO accepts each
103
113
  * top-level section value (and array element) on its own. Agents that yield
@@ -202,10 +212,12 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
202
212
  lenientArgValidation = true;
203
213
 
204
214
  readonly #validate?: (value: unknown) => JsonSchemaValidationResult;
215
+ readonly #validateSection?: ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult>;
205
216
  #schemaValidationFailures = 0;
206
217
 
207
218
  constructor(session: ToolSession) {
208
219
  let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
220
+ let validateSection: ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult> | undefined;
209
221
  let parameters: TSchema;
210
222
 
211
223
  try {
@@ -217,6 +229,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
217
229
  } = buildOutputValidator(session.outputSchema);
218
230
  if (validator) {
219
231
  validate = value => validator.validate(value);
232
+ validateSection = validator.validateSection;
220
233
  }
221
234
 
222
235
  const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema);
@@ -266,6 +279,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
266
279
  }
267
280
 
268
281
  this.#validate = validate;
282
+ this.#validateSection = validateSection;
269
283
  this.parameters = parameters;
270
284
  }
271
285
 
@@ -307,22 +321,25 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
307
321
  if (data === null) {
308
322
  throw new Error("data is required when yield indicates success");
309
323
  }
310
- if (this.#validate && !isIncremental) {
311
- const parsed = this.#validate(data);
312
- if (!parsed.success) {
313
- this.#schemaValidationFailures++;
314
- if (this.#schemaValidationFailures <= MAX_SCHEMA_RETRIES) {
315
- const remaining = MAX_SCHEMA_RETRIES - this.#schemaValidationFailures;
316
- const retryHint =
317
- remaining > 0
318
- ? ` Call yield again with the corrected shape — ${remaining} retry attempt(s) remain before the schema constraint is dropped.`
319
- : " Call yield again with the corrected shape — this is the final retry before the schema constraint is dropped.";
320
- throw new Error(
321
- `Output does not match schema: ${formatAllValidationIssues(parsed.issues)}.${retryHint}`,
322
- );
323
- }
324
- schemaValidationOverridden = true;
324
+ const sectionFailure = isIncremental
325
+ ? this.#validateIncrementalSection(yieldType as string[], data)
326
+ : this.#validate
327
+ ? this.#validate(data)
328
+ : undefined;
329
+ if (sectionFailure && !sectionFailure.success) {
330
+ this.#schemaValidationFailures++;
331
+ if (this.#schemaValidationFailures <= MAX_SCHEMA_RETRIES) {
332
+ const remaining = MAX_SCHEMA_RETRIES - this.#schemaValidationFailures;
333
+ const retryHint =
334
+ remaining > 0
335
+ ? ` Call yield again with the corrected shape — ${remaining} retry attempt(s) remain before the schema constraint is dropped.`
336
+ : " Call yield again with the corrected shape — this is the final retry before the schema constraint is dropped.";
337
+ const scope = isIncremental ? `Section ${formatYieldLabels(yieldType as string[])}` : "Output";
338
+ throw new Error(
339
+ `${scope} does not match schema: ${formatAllValidationIssues(sectionFailure.issues)}.${retryHint}`,
340
+ );
325
341
  }
342
+ schemaValidationOverridden = true;
326
343
  }
327
344
  }
328
345
 
@@ -344,6 +361,26 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
344
361
  },
345
362
  };
346
363
  }
364
+
365
+ /**
366
+ * Validate the `data` payload of an incremental yield (`type: ["<label>", …]`) against
367
+ * the matching property's sub-validator. Returns the first failure across all known labels,
368
+ * or `undefined` when no label is recognised (user-defined section labels stay loose) or
369
+ * when all known labels accept the value. Lets the model see the same retry feedback that
370
+ * the terminal-yield path already produces, instead of leaking the mismatch through to
371
+ * the parent's post-mortem `schema_violation`.
372
+ */
373
+ #validateIncrementalSection(labels: string[], data: unknown): JsonSchemaValidationResult | undefined {
374
+ const subValidators = this.#validateSection;
375
+ if (!subValidators || subValidators.size === 0) return undefined;
376
+ for (const label of labels) {
377
+ const sub = subValidators.get(label);
378
+ if (!sub) continue;
379
+ const parsed = sub(data);
380
+ if (!parsed.success) return parsed;
381
+ }
382
+ return undefined;
383
+ }
347
384
  }
348
385
 
349
386
  // Register subprocess tool handler for extraction + termination.