@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.
- package/CHANGELOG.md +23 -0
- package/dist/cli.js +2763 -2647
- package/dist/types/config/settings-schema.d.ts +56 -0
- package/dist/types/edit/hashline/filesystem.d.ts +1 -0
- package/dist/types/session/session-context.d.ts +0 -5
- package/dist/types/session/settings-stream-fn.d.ts +2 -2
- package/dist/types/tools/output-schema-validator.d.ts +10 -0
- package/package.json +12 -12
- package/src/config/settings-schema.ts +39 -1
- package/src/discovery/builtin-rules/go-add-cleanup.md +32 -0
- package/src/discovery/builtin-rules/go-bench-loop.md +36 -0
- package/src/discovery/builtin-rules/go-exp-promoted.md +39 -0
- package/src/discovery/builtin-rules/go-ioutil.md +36 -0
- package/src/discovery/builtin-rules/go-join-hostport.md +29 -0
- package/src/discovery/builtin-rules/go-new-expr.md +44 -0
- package/src/discovery/builtin-rules/go-rand-v2.md +40 -0
- package/src/discovery/builtin-rules/go-range-int.md +45 -0
- package/src/discovery/builtin-rules/index.ts +16 -0
- package/src/edit/hashline/filesystem.ts +12 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/prompts/tools/bash.md +1 -4
- package/src/session/agent-session.ts +108 -6
- package/src/session/session-context.ts +13 -2
- package/src/session/settings-stream-fn.ts +12 -2
- package/src/tools/grep.ts +19 -1
- package/src/tools/output-schema-validator.ts +38 -0
- package/src/tools/yield.ts +52 -15
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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,
|
package/src/tools/yield.ts
CHANGED
|
@@ -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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
this.#
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
`
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
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.
|